Skip to content

Commit 4785224

Browse files
HexHexaField
authored andcommitted
Support Radicle as canonical workspace org
1 parent f8734e9 commit 4785224

File tree

8 files changed

+295
-44
lines changed

8 files changed

+295
-44
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Sovereign is built on a handful of non-negotiable ideas:
1616

1717
### Orgs & Projects
1818

19-
Multi-org workspace with shared context across projects. Each org contains git repositories as first-class projects with branches, worktrees, and cross-project synchronisation. A global workspace spans all orgs for personal notes, memory, and coordination.
19+
Multi-org workspace with shared context across projects. Each org is the canonical workspace membrane and contains git repositories as first-class projects with branches, worktrees, and cross-project synchronisation. Projects can expose multiple remotes (for example Radicle as canonical plus secondary GitHub remotes) without forcing a 1:1 mapping between workspace orgs and GitHub orgs. A global workspace spans all orgs for personal notes, memory, and coordination.
2020

2121
### Code
2222

packages/server/src/drafts/routes.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,24 @@ describe('Draft REST API', () => {
271271
expect(callArgs[2].title).toBe('My Issue')
272272
})
273273

274+
it('4.1 MUST publish to the canonical remote when Radicle is ordered first', async () => {
275+
const issueTracker = createMockIssueTracker()
276+
const getRemotes = vi.fn().mockReturnValue([
277+
{ name: 'rad', provider: 'radicle' },
278+
{ name: 'origin', provider: 'github' }
279+
])
280+
const { app, store } = createApp(dataDir, { issueTracker, getRemotes })
281+
const draft = store.create({ title: 'My Radicle Issue' })
282+
283+
await request(app).post(`/api/drafts/${draft.id}/publish`).send({ orgId: 'org1', projectId: 'proj1' })
284+
285+
expect(issueTracker.create).toHaveBeenCalledWith(
286+
'org1',
287+
'proj1',
288+
expect.objectContaining({ remote: 'rad', title: 'My Radicle Issue' })
289+
)
290+
})
291+
274292
it('4.1 MUST set draft status to published and set publishedAs', async () => {
275293
const { app, store } = createApp(dataDir)
276294
const draft = store.create({ title: 'Test' })

packages/server/src/index.ts

Lines changed: 8 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ import { createReviewSystem } from './review/review.js'
5959
import { createReviewRouter } from './review/routes.js'
6060
import { createRadicleManager } from './radicle/radicle.js'
6161
import { createRadicleRouter } from './radicle/routes.js'
62+
import { orderRemotes, parseGitRemotes } from './remotes/discovery.js'
6263

6364
// --- Phase 5: Planning ---
6465
import { createPlanningService } from './planning/planning.js'
@@ -254,8 +255,10 @@ function getRemotes(
254255
projectId?: string
255256
}> = []
256257

258+
const org = orgManager.getOrg(orgId)
259+
257260
// Determine which projects to scan
258-
let projects: Array<{ id: string; repoPath: string }> = []
261+
let projects: Array<{ id: string; repoPath: string; remote?: string }> = []
259262
if (projectId) {
260263
const project = orgManager.getProject(orgId, projectId)
261264
if (project) projects = [project]
@@ -269,55 +272,17 @@ function getRemotes(
269272
}
270273

271274
for (const project of projects) {
272-
const remotes = parseGitRemotes(project.repoPath)
275+
const remotes = orderRemotes(parseGitRemotes(project.repoPath), {
276+
preferredRemoteName: project.remote,
277+
preferredProvider: org?.provider
278+
})
273279
for (const remote of remotes) {
274280
allRemotes.push({ ...remote, projectId: project.id })
275281
}
276282
}
277283

278284
return allRemotes
279285
}
280-
281-
function parseGitRemotes(
282-
repoPath: string
283-
): Array<{ name: string; provider: 'github' | 'radicle'; repo?: string; rid?: string }> {
284-
try {
285-
const gitConfigPath = path.join(repoPath, '.git', 'config')
286-
if (!fs.existsSync(gitConfigPath)) return []
287-
const config = fs.readFileSync(gitConfigPath, 'utf-8')
288-
const remotes: Array<{ name: string; provider: 'github' | 'radicle'; repo?: string; rid?: string }> = []
289-
290-
const remoteRegex = /\[remote\s+"([^"]+)"\]\s*\n([\s\S]*?)(?=\n\[|\n*$)/g
291-
let match
292-
while ((match = remoteRegex.exec(config)) !== null) {
293-
const remoteName = match[1]
294-
const section = match[2]
295-
const urlMatch = section.match(/url\s*=\s*(.+)/)
296-
if (!urlMatch) continue
297-
const url = urlMatch[1].trim()
298-
299-
// GitHub: git@github.com:owner/repo.git or https://github.com/owner/repo.git
300-
const ghSsh = url.match(/github\.com[:/]([^/]+\/[^/.]+?)(?:\.git)?\/?$/)
301-
const ghHttps = url.match(/github\.com\/([^/]+\/[^/.]+?)(?:\.git)?\/?$/)
302-
if (ghSsh || ghHttps) {
303-
const repo = (ghSsh || ghHttps)![1]
304-
remotes.push({ name: remoteName, provider: 'github', repo })
305-
continue
306-
}
307-
308-
// Radicle: rad:z... or similar
309-
const radMatch = url.match(/^rad:(.+)$/)
310-
if (radMatch) {
311-
remotes.push({ name: remoteName, provider: 'radicle', rid: radMatch[1] })
312-
continue
313-
}
314-
}
315-
316-
return remotes
317-
} catch {
318-
return []
319-
}
320-
}
321286
const issueTracker = createIssueTracker(bus, dataDir, getRemotes)
322287
app.use(createIssueRouter(issueTracker))
323288

packages/server/src/orgs/orgs.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ function fakeGitRepo(base: string, name: string = 'repo'): string {
2020
return p
2121
}
2222

23+
function fakeGitRepoWithConfig(base: string, name: string, config: string): string {
24+
const p = fakeGitRepo(base, name)
25+
fs.writeFileSync(path.join(p, '.git', 'config'), config)
26+
return p
27+
}
28+
2329
let dataDir: string
2430
let tempBase: string
2531
let bus: ReturnType<typeof createEventBus>
@@ -128,6 +134,18 @@ describe('Org Manager', () => {
128134
expect(updated.name).toBe('renamed')
129135
})
130136

137+
it('derives project preferred remote from canonical org provider when remotes are discovered', () => {
138+
const org = manager.createOrg({ name: 'Test', path: tempBase, provider: 'radicle' })
139+
const repo = fakeGitRepoWithConfig(
140+
tempBase,
141+
'repo-with-remotes',
142+
`[remote "origin"]\n url = git@github.com:secondary/repo.git\n[remote "rad"]\n url = rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5\n`
143+
)
144+
145+
const project = manager.addProject(org.id, { name: 'proj', repoPath: repo })
146+
expect(project.remote).toBe('rad')
147+
})
148+
131149
it('removes a project from an org', () => {
132150
const org = manager.createOrg({ name: 'Test', path: tempBase })
133151
const repo = fakeGitRepo(tempBase)

packages/server/src/orgs/orgs.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { EventBus } from '@sovereign/core'
55
import type { Org, Project } from './types.js'
66
import { createOrgStore, type OrgStore, type OrgStoreData } from './store.js'
77
import { detectMonorepo } from './monorepo.js'
8+
import { getProjectPreferredRemote } from '../remotes/discovery.js'
89

910
export interface OrgManager {
1011
createOrg(data: { id?: string; name: string; path: string; provider?: 'radicle' | 'github' }): Org
@@ -109,11 +110,13 @@ export function createOrgManager(bus: EventBus, dataDir: string): OrgManager {
109110
if (existing) throw new Error(`repoPath already belongs to org ${existing.orgId}`)
110111

111112
const mono = detectMonorepo(data.repoPath)
113+
const org = getOrg(orgId)
112114
const project: Project = {
113115
id: id(),
114116
orgId,
115117
name: data.name,
116118
repoPath: data.repoPath,
119+
remote: getProjectPreferredRemote(org, undefined, data.repoPath),
117120
defaultBranch: 'main',
118121
monorepo: mono || undefined,
119122
createdAt: now(),
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { orderRemotes, selectPreferredRemote } from './discovery.js'
3+
import type { Remote } from '../issues/types.js'
4+
5+
describe('remote discovery ordering', () => {
6+
const remotes: Remote[] = [
7+
{ name: 'origin', provider: 'github', repo: 'secondary/repo' },
8+
{ name: 'rad', provider: 'radicle', rid: 'z3gqcJUoA1n9HaHKufZs5FCSGazv5' }
9+
]
10+
11+
it('prefers explicit project remote over provider ordering', () => {
12+
const ordered = orderRemotes(remotes, { preferredRemoteName: 'origin', preferredProvider: 'radicle' })
13+
expect(ordered.map((remote) => remote.name)).toEqual(['origin', 'rad'])
14+
})
15+
16+
it('prefers canonical provider when no explicit project remote is set', () => {
17+
const ordered = orderRemotes(remotes, { preferredProvider: 'radicle' })
18+
expect(ordered.map((remote) => remote.name)).toEqual(['rad', 'origin'])
19+
})
20+
21+
it('selects canonical provider remote for default write flows', () => {
22+
const selected = selectPreferredRemote(remotes, { preferredProvider: 'radicle' })
23+
expect(selected?.name).toBe('rad')
24+
})
25+
})
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import fs from 'node:fs'
2+
import path from 'node:path'
3+
import type { Org, Project } from '../orgs/types.js'
4+
import type { Remote } from '../issues/types.js'
5+
6+
export function parseGitRemotes(repoPath: string): Remote[] {
7+
try {
8+
const gitConfigPath = path.join(repoPath, '.git', 'config')
9+
if (!fs.existsSync(gitConfigPath)) return []
10+
const config = fs.readFileSync(gitConfigPath, 'utf-8')
11+
const remotes: Remote[] = []
12+
13+
const remoteRegex = /\[remote\s+"([^"]+)"\]\s*\n([\s\S]*?)(?=\n\[|\n*$)/g
14+
let match: RegExpExecArray | null
15+
16+
while ((match = remoteRegex.exec(config)) !== null) {
17+
const remoteName = match[1]
18+
const section = match[2]
19+
const urlMatch = section?.match(/url\s*=\s*(.+)/)
20+
if (!urlMatch) continue
21+
const url = urlMatch[1].trim()
22+
23+
const ghSsh = url.match(/github\.com[:/]([^/]+\/[^/.]+?)(?:\.git)?\/?$/)
24+
const ghHttps = url.match(/github\.com\/([^/]+\/[^/.]+?)(?:\.git)?\/?$/)
25+
if (ghSsh || ghHttps) {
26+
remotes.push({ name: remoteName, provider: 'github', repo: (ghSsh || ghHttps)![1] })
27+
continue
28+
}
29+
30+
const radMatch = url.match(/^rad:(.+)$/)
31+
if (radMatch) {
32+
remotes.push({ name: remoteName, provider: 'radicle', rid: radMatch[1] })
33+
}
34+
}
35+
36+
return remotes
37+
} catch {
38+
return []
39+
}
40+
}
41+
42+
export function orderRemotes(
43+
remotes: Remote[],
44+
opts?: { preferredRemoteName?: string; preferredProvider?: Org['provider'] }
45+
): Remote[] {
46+
if (remotes.length <= 1) return [...remotes]
47+
48+
const preferredByName = opts?.preferredRemoteName ? remotes.findIndex((r) => r.name === opts.preferredRemoteName) : -1
49+
const preferredIndex =
50+
preferredByName >= 0 ? preferredByName : remotes.findIndex((r) => r.provider === opts?.preferredProvider)
51+
52+
if (preferredIndex <= 0) return [...remotes]
53+
54+
const preferred = remotes[preferredIndex]!
55+
return [preferred, ...remotes.slice(0, preferredIndex), ...remotes.slice(preferredIndex + 1)]
56+
}
57+
58+
export function selectPreferredRemote(
59+
remotes: Remote[],
60+
opts?: { preferredRemoteName?: string; preferredProvider?: Org['provider'] }
61+
): Remote | undefined {
62+
return orderRemotes(remotes, opts)[0]
63+
}
64+
65+
export function getProjectPreferredRemote(
66+
org: Org | undefined,
67+
project: Pick<Project, 'remote'> | undefined,
68+
repoPath: string
69+
): string | undefined {
70+
const remotes = parseGitRemotes(repoPath)
71+
return selectPreferredRemote(remotes, {
72+
preferredRemoteName: project?.remote,
73+
preferredProvider: org?.provider
74+
})?.name
75+
}

0 commit comments

Comments
 (0)