Skip to content

Commit e83d8e7

Browse files
authored
Projects page Github badges (#5094)
* add column * update github repo * when updating settings, update the db repo * test update * test handler * github limits * remove copypasta * test helper * fix assert * bff check
1 parent 69b05c2 commit e83d8e7

15 files changed

+450
-25
lines changed

editor/src/components/editor/actions/actions.tsx

+19-4
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,7 @@ import {
350350
saveAsset as saveAssetToServer,
351351
saveUserConfiguration,
352352
updateAssetFileName,
353+
updateGithubRepository,
353354
} from '../server'
354355
import type {
355356
CanvasBase64Blobs,
@@ -366,6 +367,7 @@ import type {
366367
TrueUpTarget,
367368
TrueUpHuggingElement,
368369
CollaborativeEditingSupport,
370+
ProjectGithubSettings,
369371
} from '../store/editor-state'
370372
import {
371373
trueUpChildrenOfGroupChanged,
@@ -3612,12 +3614,25 @@ export const UPDATE_FNS = {
36123614
}
36133615
},
36143616
UPDATE_GITHUB_SETTINGS: (action: UpdateGithubSettings, editor: EditorModel): EditorModel => {
3617+
const newGithubSettings: ProjectGithubSettings = {
3618+
...editor.githubSettings,
3619+
...action.settings,
3620+
}
3621+
if (editor.id != null) {
3622+
void updateGithubRepository(
3623+
editor.id,
3624+
newGithubSettings.targetRepository == null
3625+
? null
3626+
: {
3627+
owner: newGithubSettings.targetRepository.owner,
3628+
repository: newGithubSettings.targetRepository.repository,
3629+
branch: newGithubSettings.branchName,
3630+
},
3631+
)
3632+
}
36153633
return normalizeGithubData({
36163634
...editor,
3617-
githubSettings: {
3618-
...editor.githubSettings,
3619-
...action.settings,
3620-
},
3635+
githubSettings: newGithubSettings,
36213636
})
36223637
},
36233638
UPDATE_GITHUB_DATA: (action: UpdateGithubData, editor: EditorModel): EditorModel => {

editor/src/components/editor/server.ts

+26-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@ import {
88
thumbnailURL,
99
userConfigURL,
1010
} from '../../common/server'
11-
import type { PersistentModel, UserConfiguration, UserPermissions } from './store/editor-state'
11+
import type {
12+
GithubRepo,
13+
PersistentModel,
14+
UserConfiguration,
15+
UserPermissions,
16+
} from './store/editor-state'
1217
import { emptyUserConfiguration, emptyUserPermissions } from './store/editor-state'
1318
import type { LoginState } from '../../uuiui-deps'
1419
import urljoin from 'url-join'
@@ -609,3 +614,23 @@ export async function requestProjectAccess(projectId: string): Promise<void> {
609614
throw new Error(`Request project access failed (${response.status}): ${response.statusText}`)
610615
}
611616
}
617+
618+
export async function updateGithubRepository(
619+
projectId: string,
620+
githubRepository: (GithubRepo & { branch: string | null }) | null,
621+
): Promise<void> {
622+
if (!isBackendBFF()) {
623+
return
624+
}
625+
const url = urljoin(`/internal/projects/${projectId}/github/repository/update`)
626+
const response = await fetch(url, {
627+
method: 'POST',
628+
credentials: 'include',
629+
headers: HEADERS,
630+
mode: MODE,
631+
body: JSON.stringify({ githubRepository: githubRepository }),
632+
})
633+
if (!response.ok) {
634+
throw new Error(`Update Github repository failed (${response.status}): ${response.statusText}`)
635+
}
636+
}

server/migrations/010.sql

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
ALTER TABLE project
2+
ADD COLUMN github_repository text;
3+

server/src/Utopia/Web/Database/Migrations.hs

+1
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ migrateDatabase verbose includeInitial pool = withResource pool $ \connection ->
3131
, MigrationFile "007.sql" "./migrations/007.sql"
3232
, MigrationFile "008.sql" "./migrations/008.sql"
3333
, MigrationFile "009.sql" "./migrations/009.sql"
34+
, MigrationFile "010.sql" "./migrations/010.sql"
3435
]
3536
let initialMigrationCommand = if includeInitial
3637
then [MigrationFile "initial.sql" "./migrations/initial.sql"]

utopia-remix/app/models/project.server.spec.ts

+106-1
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,15 @@ import {
1818
renameProject,
1919
restoreDeletedProject,
2020
softDeleteProject,
21+
updateGithubRepository,
2122
} from './project.server'
22-
import { AccessLevel, AccessRequestStatus } from '../types'
23+
import {
24+
AccessLevel,
25+
MaxGithubBranchNameLength,
26+
MaxGithubOwnerLength,
27+
MaxGithubRepositoryLength,
28+
AccessRequestStatus,
29+
} from '../types'
2330

2431
describe('project model', () => {
2532
afterEach(async () => {
@@ -419,4 +426,102 @@ describe('project model', () => {
419426
expect(got.collaborators['four'].map((c) => c.id)[1]).toBe('carol')
420427
})
421428
})
429+
430+
describe('updateGithubRepository', () => {
431+
beforeEach(async () => {
432+
await createTestUser(prisma, { id: 'bob' })
433+
await createTestUser(prisma, { id: 'alice' })
434+
await createTestProject(prisma, { id: 'one', ownerId: 'bob' })
435+
await prisma.project.update({
436+
where: { proj_id: 'one' },
437+
data: { github_repository: 'something' },
438+
})
439+
})
440+
441+
it('errors if the project is not found', async () => {
442+
const fn = async () =>
443+
updateGithubRepository({ projectId: 'unknown', userId: 'bob', repository: null })
444+
await expect(fn).rejects.toThrow('not found')
445+
})
446+
447+
it('errors if the user does not own the project', async () => {
448+
const fn = async () =>
449+
updateGithubRepository({ projectId: 'one', userId: 'alice', repository: null })
450+
await expect(fn).rejects.toThrow('not found')
451+
})
452+
453+
it('updates the repository string (null)', async () => {
454+
await updateGithubRepository({ projectId: 'one', userId: 'bob', repository: null })
455+
const project = await prisma.project.findUnique({
456+
where: { proj_id: 'one' },
457+
select: { github_repository: true },
458+
})
459+
if (project == null) {
460+
throw new Error('expected project not to be null')
461+
}
462+
expect(project.github_repository).toBe(null)
463+
})
464+
465+
it('updates the repository string (without branch)', async () => {
466+
await updateGithubRepository({
467+
projectId: 'one',
468+
userId: 'bob',
469+
repository: { owner: 'foo', repository: 'bar', branch: null },
470+
})
471+
const project = await prisma.project.findUnique({
472+
where: { proj_id: 'one' },
473+
select: { github_repository: true },
474+
})
475+
if (project == null) {
476+
throw new Error('expected project not to be null')
477+
}
478+
expect(project.github_repository).toBe('foo/bar')
479+
})
480+
481+
it('updates the repository string (with branch)', async () => {
482+
await updateGithubRepository({
483+
projectId: 'one',
484+
userId: 'bob',
485+
repository: { owner: 'foo', repository: 'bar', branch: 'baz' },
486+
})
487+
const project = await prisma.project.findUnique({
488+
where: { proj_id: 'one' },
489+
select: { github_repository: true },
490+
})
491+
if (project == null) {
492+
throw new Error('expected project not to be null')
493+
}
494+
expect(project.github_repository).toBe('foo/bar:baz')
495+
})
496+
497+
it('updates the repository string (with branch), trimming to max lengths', async () => {
498+
const repo = {
499+
owner:
500+
'foooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo',
501+
repository:
502+
'barrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr',
503+
branch:
504+
'bazzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz',
505+
}
506+
await updateGithubRepository({
507+
projectId: 'one',
508+
userId: 'bob',
509+
repository: repo,
510+
})
511+
const project = await prisma.project.findUnique({
512+
where: { proj_id: 'one' },
513+
select: { github_repository: true },
514+
})
515+
if (project == null) {
516+
throw new Error('expected project not to be null')
517+
}
518+
expect(project.github_repository).toBe(
519+
repo.owner.slice(0, MaxGithubOwnerLength) +
520+
'/' +
521+
repo.repository.slice(0, MaxGithubRepositoryLength) +
522+
':' +
523+
repo.branch.slice(0, MaxGithubBranchNameLength),
524+
)
525+
})
526+
})
422527
})

utopia-remix/app/models/project.server.ts

+20-1
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { prisma } from '../db.server'
2-
import type { CollaboratorsByProject, ProjectListing } from '../types'
2+
import type { CollaboratorsByProject, GithubRepository, ProjectListing } from '../types'
33
import {
44
AccessLevel,
55
AccessRequestStatus,
66
asAccessLevel,
77
userToCollaborator,
8+
githubRepositoryStringOrNull,
89
type ProjectWithoutContentFromDB,
910
} from '../types'
1011
import { ensure } from '../util/api.server'
@@ -19,6 +20,7 @@ const selectProjectWithoutContent: Record<keyof ProjectWithoutContentFromDB, tru
1920
modified_at: true,
2021
deleted: true,
2122
ProjectAccess: true,
23+
github_repository: true,
2224
}
2325

2426
export async function listProjects(params: { ownerId: string }): Promise<ProjectListing[]> {
@@ -195,3 +197,20 @@ export async function listSharedWithMeProjectsAndCollaborators(params: {
195197
collaborators: collaboratorsByProject,
196198
}
197199
}
200+
201+
export async function updateGithubRepository(params: {
202+
projectId: string
203+
userId: string
204+
repository: GithubRepository | null
205+
}) {
206+
return prisma.project.update({
207+
where: {
208+
owner_id: params.userId,
209+
proj_id: params.projectId,
210+
},
211+
data: {
212+
github_repository: githubRepositoryStringOrNull(params.repository),
213+
modified_at: new Date(),
214+
},
215+
})
216+
}

utopia-remix/app/models/projectCollaborators.server.spec.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ describe('projectCollaborators model', () => {
5353
it('returns the collaborator details by project id', async () => {
5454
const ids = ['one', 'two', 'four', 'five']
5555
const got = await getCollaborators({ ids: ids, userId: 'bob' })
56-
expect(Object.keys(got)).toEqual(ids)
56+
expect(Object.keys(got).length).toEqual(4)
5757
expect(got['one'].map((c) => c.id)).toEqual(['bob'])
5858
expect(got['two'].map((c) => c.id)).toEqual(['bob', 'wendy'])
5959
expect(got['four'].map((c) => c.id)).toEqual([])
@@ -62,7 +62,7 @@ describe('projectCollaborators model', () => {
6262
it('ignores mismatching projects', async () => {
6363
const ids = ['one', 'two', 'three']
6464
const got = await getCollaborators({ ids: ids, userId: 'bob' })
65-
expect(Object.keys(got)).toEqual(['one', 'two'])
65+
expect(Object.keys(got).length).toEqual(2)
6666
expect(got['one'].map((c) => c.id)).toEqual(['bob'])
6767
expect(got['two'].map((c) => c.id)).toEqual(['bob', 'wendy'])
6868
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { prisma } from '../db.server'
2+
import { handleUpdateGithubRepository } from '../routes/internal.projects.$id.github.repository.update'
3+
import {
4+
createTestProject,
5+
createTestSession,
6+
createTestUser,
7+
newTestRequest,
8+
truncateTables,
9+
} from '../test-util'
10+
import { ApiError } from '../util/errors'
11+
12+
describe('handleUpdateGithubRepository', () => {
13+
afterEach(async () => {
14+
await truncateTables([
15+
prisma.userDetails,
16+
prisma.persistentSession,
17+
prisma.project,
18+
prisma.projectID,
19+
])
20+
})
21+
22+
beforeEach(async () => {
23+
await createTestUser(prisma, { id: 'bob' })
24+
await createTestUser(prisma, { id: 'alice' })
25+
await createTestSession(prisma, { key: 'the-key', userId: 'bob' })
26+
await createTestProject(prisma, { id: 'one', ownerId: 'bob' })
27+
await createTestProject(prisma, { id: 'two', ownerId: 'alice' })
28+
})
29+
30+
it('requires a user', async () => {
31+
const fn = async () =>
32+
handleUpdateGithubRepository(newTestRequest({ method: 'POST', authCookie: 'wrong-key' }), {})
33+
await expect(fn).rejects.toThrow(ApiError)
34+
await expect(fn).rejects.toThrow('session not found')
35+
})
36+
it('requires a valid id', async () => {
37+
const fn = async () =>
38+
handleUpdateGithubRepository(newTestRequest({ method: 'POST', authCookie: 'the-key' }), {})
39+
await expect(fn).rejects.toThrow(ApiError)
40+
await expect(fn).rejects.toThrow('id is null')
41+
})
42+
it('requires a valid request body', async () => {
43+
const fn = async () => {
44+
const req = newTestRequest({
45+
method: 'POST',
46+
authCookie: 'the-key',
47+
body: JSON.stringify({}),
48+
})
49+
return handleUpdateGithubRepository(req, { id: 'one' })
50+
}
51+
52+
await expect(fn).rejects.toThrow('invalid request')
53+
})
54+
it('requires a valid project', async () => {
55+
const fn = async () => {
56+
const req = newTestRequest({
57+
method: 'POST',
58+
authCookie: 'the-key',
59+
body: JSON.stringify({ githubRepository: null }),
60+
})
61+
return handleUpdateGithubRepository(req, { id: 'doesnt-exist' })
62+
}
63+
64+
await expect(fn).rejects.toThrow('Record to update not found')
65+
})
66+
it('requires ownership of the project', async () => {
67+
const fn = async () => {
68+
const req = newTestRequest({
69+
method: 'POST',
70+
authCookie: 'the-key',
71+
body: JSON.stringify({ githubRepository: null }),
72+
})
73+
return handleUpdateGithubRepository(req, { id: 'two' })
74+
}
75+
76+
await expect(fn).rejects.toThrow('Record to update not found')
77+
})
78+
it('updates the github repository', async () => {
79+
const fn = async () => {
80+
const req = newTestRequest({
81+
method: 'POST',
82+
authCookie: 'the-key',
83+
body: JSON.stringify({
84+
githubRepository: { owner: 'foo', repository: 'bar', branch: 'baz' },
85+
}),
86+
})
87+
return handleUpdateGithubRepository(req, { id: 'one' })
88+
}
89+
90+
await fn()
91+
const got = await prisma.project.findUnique({
92+
where: { proj_id: 'one' },
93+
select: { github_repository: true },
94+
})
95+
expect(got?.github_repository).toEqual('foo/bar:baz')
96+
})
97+
})

0 commit comments

Comments
 (0)