Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
8aa2fcd
refactor: reuse auth url creation
merll Jun 26, 2026
bbc0250
fix: credentials for optional gitea and improved error handling
merll Jun 26, 2026
1fdcd82
Merge remote-tracking branch 'origin/main' into APL-1925
merll Jun 26, 2026
f33d5da
Merge remote-tracking branch 'origin/main' into APL-1925
svcAPLBot Jun 29, 2026
f47f0bb
fix: ensure by flags the file is new
merll Jun 29, 2026
7d5f0eb
fix: internal gitea url check
merll Jun 29, 2026
f501fe9
chore: reuse ssh check
merll Jun 29, 2026
e3ccbb5
fix: consider error object being empty
merll Jun 29, 2026
30e4650
chore: removed unused functions
merll Jun 29, 2026
469e5eb
test: do not assume that ssh and https should be mixed
merll Jun 29, 2026
3645bf0
refactor: one place to check for internal gitea url
merll Jun 29, 2026
4209f19
fix: do not attempt to normalize public ssh url
merll Jun 29, 2026
bd953d8
chore: linter fix
merll Jun 29, 2026
2223017
fix: avoid error if message cannot be extracted
merll Jun 29, 2026
b7aeffb
test: add dedicated tests to internal git url path
merll Jun 29, 2026
c39f384
test: fix mock return
merll Jun 30, 2026
fcea901
test: function is renamed
merll Jun 30, 2026
32beeb0
fix: avoid error during handling
merll Jun 30, 2026
dc8b435
Merge remote-tracking branch 'origin/main' into APL-1925
svcAPLBot Jun 30, 2026
6e5f335
fix: ssh authentication in simple-git requires option
merll Jun 30, 2026
f88176a
fix: adding missing import
merll Jun 30, 2026
9c1e1ad
fix: chart catalog to use common gitea authentication
merll Jun 30, 2026
fd599d0
Merge remote-tracking branch 'origin/main' into APL-1925
j-zimnowoda Jul 1, 2026
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
3 changes: 1 addition & 2 deletions src/git/connect.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import Debug from 'debug'
import { GitConfig } from '../otomi-models'
import { Git } from '../git'

const debug = Debug('otomi:git-connect')
Expand All @@ -12,7 +11,7 @@ export function getUrl(url: string): string {
return !url || url.includes('://') ? url : `${getProtocol(url)}://${url}`
}

export function getAuthenticatedUrl(gitConfig: GitConfig): string {
export function getAuthenticatedUrl(gitConfig: { repoUrl: string; username?: string; password: string }): string {
const protocol = getProtocol(gitConfig.repoUrl)
if (protocol === 'file') {
return gitConfig.repoUrl
Expand Down
2 changes: 2 additions & 0 deletions src/openapi/testrepoconnect.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,6 @@ TestRepoConnect:
- unknown
- success
- failed
message:
type: string
type: object
184 changes: 184 additions & 0 deletions src/otomi-stack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import {
import OtomiStack from 'src/otomi-stack'
import { loadSpec } from './app'
import { BadRequestError, NotExistError, ValidationError } from './error'
import { pathExists, unlink } from 'fs-extra'
import { Git } from './git'
import { extractRepositoryRefs, getAuthenticatedGitClient } from './utils/codeRepoUtils'

jest.mock('./tty', () => ({
__esModule: true,
Expand Down Expand Up @@ -69,6 +71,18 @@ jest.mock('./utils/sealedSecretUtils', () => {
}
})

jest.mock('./utils/codeRepoUtils', () => ({
...jest.requireActual('./utils/codeRepoUtils'),
getAuthenticatedGitClient: jest.fn(),
extractRepositoryRefs: jest.fn(),
}))

jest.mock('fs-extra', () => ({
...jest.requireActual('fs-extra'),
pathExists: jest.fn().mockResolvedValue(false),
unlink: jest.fn().mockResolvedValue(undefined),
}))

beforeAll(async () => {
jest.spyOn(console, 'log').mockImplementation(() => {})
jest.spyOn(console, 'debug').mockImplementation(() => {})
Expand Down Expand Up @@ -1491,3 +1505,173 @@ describe('OtomiStack locked state', () => {
expect(stack.getApiStatus()).toEqual({ locked: false })
})
})

describe('getRepoBranches', () => {
let otomiStack: OtomiStack

beforeEach(async () => {
otomiStack = new OtomiStack()
await otomiStack.init()

const { FileStore } = require('./fileStore/file-store')
otomiStack.fileStore = new FileStore()

createTestTeam(otomiStack, 'demo', {})

const codeRepo: AplCodeRepoResponse = {
kind: 'AplTeamCodeRepo',
metadata: { name: 'code-1', labels: { 'apl.io/teamId': 'demo' } },
spec: { gitService: 'gitea', repositoryUrl: 'https://gitea.test.com' },
status: {},
}
otomiStack.fileStore.setTeamResource(codeRepo)

jest.spyOn(otomiStack, 'getApp').mockReturnValue({ id: 'gitea', values: { enabled: true } } as App)
jest
.spyOn(otomiStack, 'getSettings')
.mockResolvedValue({ cluster: { name: '', provider: 'custom', domainSuffix: 'test.example.com' } })
})

afterEach(() => {
jest.restoreAllMocks()
})

it('should return ["HEAD"] when no codeRepoName is provided', async () => {
const result = await otomiStack.getRepoBranches('', 'demo')
expect(result).toEqual(['HEAD'])
})

it('should return ["HEAD"] when repo has no repositoryUrl', async () => {
const codeRepoNoUrl: AplCodeRepoResponse = {
kind: 'AplTeamCodeRepo',
metadata: { name: 'no-url-repo', labels: { 'apl.io/teamId': 'demo' } },
spec: { gitService: 'gitea' } as any,
status: {},
}
otomiStack.fileStore.setTeamResource(codeRepoNoUrl)

const result = await otomiStack.getRepoBranches('no-url-repo', 'demo')
expect(result).toEqual(['HEAD'])
})

it('should return branches from extractRepositoryRefs', async () => {
const mockGitInstance = { listRemote: jest.fn() }
;(getAuthenticatedGitClient as jest.Mock).mockResolvedValue({ git: mockGitInstance, url: 'https://gitea.test.com' })
;(extractRepositoryRefs as jest.Mock).mockResolvedValue(['main', 'develop'])

const result = await otomiStack.getRepoBranches('code-1', 'demo')

expect(getAuthenticatedGitClient).toHaveBeenCalledWith(
'https://gitea.test.com',
'demo',
'test.example.com',
expect.anything(),
undefined,
)
expect(extractRepositoryRefs).toHaveBeenCalledWith('https://gitea.test.com', mockGitInstance)
expect(result).toEqual(['main', 'develop'])
})

it('should return [] when extractRepositoryRefs throws', async () => {
const mockGitInstance = { listRemote: jest.fn() }
;(getAuthenticatedGitClient as jest.Mock).mockResolvedValue({ git: mockGitInstance, url: 'https://gitea.test.com' })
;(extractRepositoryRefs as jest.Mock).mockRejectedValue(new Error('Network error'))

const result = await otomiStack.getRepoBranches('code-1', 'demo')
expect(result).toEqual([])
})

it('should return [] when getAuthenticatedGitClient throws', async () => {
;(getAuthenticatedGitClient as jest.Mock).mockRejectedValue(new Error('Auth failed'))

const result = await otomiStack.getRepoBranches('code-1', 'demo')
expect(result).toEqual([])
})

it('should clean up the SSH key file after fetching branches', async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't the keypath also be cleaned up in case an error is thrown?

const keyPath = '/tmp/otomi/sshKey-test'
const mockGitInstance = { listRemote: jest.fn() }
;(getAuthenticatedGitClient as jest.Mock).mockResolvedValue({
git: mockGitInstance,
url: 'https://gitea.test.com',
keyPath,
})
;(extractRepositoryRefs as jest.Mock).mockResolvedValue(['main'])
;(pathExists as jest.Mock).mockResolvedValue(true)

await otomiStack.getRepoBranches('code-1', 'demo')

expect(pathExists).toHaveBeenCalledWith(keyPath)
expect(unlink).toHaveBeenCalledWith(keyPath)
})
})

describe('getTestRepoConnect', () => {
let otomiStack: OtomiStack

beforeEach(async () => {
otomiStack = new OtomiStack()
await otomiStack.init()

const { FileStore } = require('./fileStore/file-store')
otomiStack.fileStore = new FileStore()

jest.spyOn(otomiStack, 'getApp').mockReturnValue({ id: 'gitea', values: { enabled: true } } as App)
jest
.spyOn(otomiStack, 'getSettings')
.mockResolvedValue({ cluster: { name: '', provider: 'custom', domainSuffix: 'test.example.com' } })
})

afterEach(() => {
jest.restoreAllMocks()
})

it('should return { status: "success" } when git.listRemote succeeds', async () => {
const mockGitInstance = { listRemote: jest.fn().mockResolvedValue('') }
;(getAuthenticatedGitClient as jest.Mock).mockResolvedValue({
git: mockGitInstance,
url: 'https://gitea.test.com',
})

const result = await otomiStack.getTestRepoConnect('https://gitea.test.com', 'demo', 'my-secret')

expect(result).toEqual({ status: 'success' })
expect(mockGitInstance.listRemote).toHaveBeenCalledWith(['https://gitea.test.com'])
})

it('should return { status: "failed" } when git.listRemote throws', async () => {
const mockGitInstance = { listRemote: jest.fn().mockRejectedValue(new Error('Connection refused')) }
;(getAuthenticatedGitClient as jest.Mock).mockResolvedValue({
git: mockGitInstance,
url: 'https://gitea.test.com',
})

const result = await otomiStack.getTestRepoConnect('https://gitea.test.com', 'demo', 'my-secret')

expect(result).toEqual({ status: 'failed', message: 'Connection refused' })
})

it('should return { status: "failed" } when getAuthenticatedGitClient throws', async () => {
;(getAuthenticatedGitClient as jest.Mock).mockRejectedValue(new Error('Invalid credentials'))

const result = await otomiStack.getTestRepoConnect('https://gitea.test.com', 'demo', 'my-secret')

expect(result).toEqual({ status: 'failed', message: 'Invalid credentials' })
})

it('should clean up the SSH key file after testing connection', async () => {
const keyPath = '/tmp/otomi/sshKey-test'
const mockGitInstance = { listRemote: jest.fn().mockResolvedValue('') }
;(getAuthenticatedGitClient as jest.Mock).mockResolvedValue({
git: mockGitInstance,
url: 'https://gitea.test.com',
keyPath,
})
;(pathExists as jest.Mock).mockResolvedValue(true)

await otomiStack.getTestRepoConnect('https://gitea.test.com', 'demo', 'my-secret')

expect(pathExists).toHaveBeenCalledWith(keyPath)
expect(unlink).toHaveBeenCalledWith(keyPath)
})
})
105 changes: 45 additions & 60 deletions src/otomi-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,14 +145,7 @@ import {
watchPodUntilRunning,
} from './k8s-operations'
import CloudTty from './tty'
import {
getGiteaRepoUrls,
getPrivateRepoBranches,
getPublicRepoBranches,
normalizeRepoUrl,
testPrivateRepoConnect,
testPublicRepoConnect,
} from './utils/codeRepoUtils'
import { extractRepositoryRefs, getAuthenticatedGitClient, getGiteaRepoUrls } from './utils/codeRepoUtils'
import { isKnativeSupported } from './utils/k8sUtils'
import { getV1ObjectFromApl } from './utils/manifests'
import {
Expand All @@ -175,8 +168,9 @@ import {
userSecretDataToUser,
} from './utils/userUtils'
import { defineClusterId, ObjectStorageClient } from './utils/wizardUtils'
import { fetchWorkloadCatalog, isInteralGiteaURL } from './utils/workloadUtils'
import { fetchWorkloadCatalog } from './utils/workloadUtils'
import { getAuthenticatedUrl } from './git/connect'
import { pathExists, unlink } from 'fs-extra'

interface ExcludedApp extends App {
managed: boolean
Expand Down Expand Up @@ -1493,69 +1487,60 @@ export default class OtomiStack {

const coderepo = this.getAplCodeRepo(teamId, codeRepoName)
const { repositoryUrl, secret: secretName } = coderepo.spec

if (!repositoryUrl) return ['HEAD']
const giteaValues = this.getApp('gitea').values
const { cluster } = await this.getSettings(['cluster'])

try {
let sshPrivateKey = ''
let username = ''
let accessToken = ''

if (secretName) {
const secret = await getSecretValues(secretName, `team-${teamId}`)
sshPrivateKey = secret?.['ssh-privatekey'] || ''
username = secret?.username || ''
accessToken = secret?.password || ''
}

const isPrivate = !!secretName
const isSSH = !!sshPrivateKey

const repoUrl = isInteralGiteaURL(repositoryUrl, cluster?.domainSuffix)
? repositoryUrl
: normalizeRepoUrl(repositoryUrl, isPrivate, isSSH)

if (!repoUrl) return ['HEAD']

if (isPrivate) {
return await getPrivateRepoBranches(repoUrl, sshPrivateKey, username, accessToken)
const { git, url, keyPath } = await getAuthenticatedGitClient(
repositoryUrl,
teamId,
cluster?.domainSuffix,
giteaValues,
secretName,
)
try {
return await extractRepositoryRefs(url, git)
} catch (error) {
const errorMessage = error.response?.data?.message || error?.message || 'Failed to get repo branches'
Comment thread
Copilot marked this conversation as resolved.
Outdated
debug('Error getting branches:', errorMessage)
return []
} finally {
if (keyPath && (await pathExists(keyPath))) {
await unlink(keyPath)
}
}

return await getPublicRepoBranches(repoUrl)
} catch (error) {
const errorMessage = error.response?.data?.message || error?.message || 'Failed to get repo branches'
debug('Error getting branches:', errorMessage)
debug('Error getting branches:', error.message)
Comment thread
merll marked this conversation as resolved.
Outdated
return []
}
Comment thread
merll marked this conversation as resolved.
}

async getTestRepoConnect(url: string, teamId: string, secretName: string): Promise<TestRepoConnect> {
try {
let sshPrivateKey = '',
username = '',
accessToken = ''

const isPrivate = !!secretName

if (isPrivate) {
const secret = await getSecretValues(secretName, `team-${teamId}`)
sshPrivateKey = secret?.['ssh-privatekey'] || ''
username = secret?.username || ''
accessToken = secret?.password || ''
}

const isSSH = !!sshPrivateKey
const repoUrl = normalizeRepoUrl(url, isPrivate, isSSH)

if (!repoUrl) return { status: 'failed' }
async getTestRepoConnect(repositoryUrl: string, teamId: string, secretName: string): Promise<TestRepoConnect> {
const giteaValues = this.getApp('gitea').values
const { cluster } = await this.getSettings(['cluster'])

if (isPrivate) {
return (await testPrivateRepoConnect(repoUrl, sshPrivateKey, username, accessToken)) as TestRepoConnect
try {
const { git, url, keyPath } = await getAuthenticatedGitClient(
repositoryUrl,
teamId,
cluster?.domainSuffix,
giteaValues,
secretName,
)
try {
await git.listRemote([url])
return { status: 'success' }
} catch (error) {
const message = error.response?.data?.message || error?.message
Comment thread
Copilot marked this conversation as resolved.
Outdated
return { status: 'failed', message }
} finally {
if (keyPath && (await pathExists(keyPath))) {
await unlink(keyPath)
}
}

return (await testPublicRepoConnect(repoUrl)) as TestRepoConnect
} catch (error) {
return { status: 'failed' }
return { status: 'failed', message: error.message }
Comment thread
merll marked this conversation as resolved.
Outdated
}
}

Expand Down
Loading
Loading