diff --git a/docs/user/devops-pipelines.md b/docs/user/devops-pipelines.md index 9a7109c2a8..b8a16b11ab 100644 --- a/docs/user/devops-pipelines.md +++ b/docs/user/devops-pipelines.md @@ -66,9 +66,9 @@ There are four types of stage to chose from: 1. **[Instance](./concepts.md#hosted-instance)** - a single Node-RED instance. 2. **[Device](./concepts.md#remote-instance)** - a single remote instance. 3. **[Device Group](./concepts.md#device-groups)** - a group of remote instances. -4. **Git Repository** - a remote GitHub repository. +4. **Git Repository** - a remote GitHub/Azure DevOps repository. - This stage currently only supports: - - Repositories hosted on GitHub.com + - Repositories hosted on GitHub.com or dev.azure.com ### Actions @@ -103,7 +103,7 @@ When a Device Group stage is triggered, it will push the current active snapshot #### Git Repository stage -Git Repository stages can be used to push and pull snapshots from a GitHub hosted repository. The stage can be configured with +Git Repository stages can be used to push and pull snapshots from a GitHub or Azure DevOps hosted repository. The stage can be configured with the branch to push/pull from as well as the filename to use for the snapshot. If a filename is not configured, it will generate the filename when pushing to the repository based on the name of Instance, Device diff --git a/forge/db/migrations/20260318-01-EE-extend-gittoken.js b/forge/db/migrations/20260318-01-EE-extend-gittoken.js new file mode 100644 index 0000000000..c632ef7a8c --- /dev/null +++ b/forge/db/migrations/20260318-01-EE-extend-gittoken.js @@ -0,0 +1,16 @@ +const { DataTypes } = require('sequelize') + +module.exports = { + /** + * upgrade database + * @param {QueryInterface} context Sequelize.QueryInterface + */ + up: async (context, Sequelize) => { + await context.addColumn('GitTokens', 'type', { + type: DataTypes.STRING, + defaultValue: 'github', + allowNull: false + }) + }, + down: async (context, Sequelize) => { } +} diff --git a/forge/ee/db/models/GitToken.js b/forge/ee/db/models/GitToken.js index 6f39adca26..32cc90fef0 100644 --- a/forge/ee/db/models/GitToken.js +++ b/forge/ee/db/models/GitToken.js @@ -10,6 +10,11 @@ module.exports = { token: { type: DataTypes.STRING, allowNull: false + }, + type: { + type: DataTypes.STRING, + allowNull: false, + default: 'github' } }, associations: function (M) { diff --git a/forge/ee/db/models/PipelineStageGitRepo.js b/forge/ee/db/models/PipelineStageGitRepo.js index f22690cd5b..21453fc974 100644 --- a/forge/ee/db/models/PipelineStageGitRepo.js +++ b/forge/ee/db/models/PipelineStageGitRepo.js @@ -102,6 +102,7 @@ module.exports = { } await app.gitops.pushToRepository({ token: gitToken.token, + tokenType: gitToken.type, url: this.url, branch: this.branch, credentialSecret: this.credentialSecret, @@ -154,6 +155,7 @@ module.exports = { } const snapshotContent = await app.gitops.pullFromRepository({ token: gitToken.token, + tokenType: gitToken.type, url: this.url, branch: this.pullBranch || this.branch, credentialSecret: this.credentialSecret, diff --git a/forge/ee/db/views/GitToken.js b/forge/ee/db/views/GitToken.js index c94b59d7db..026e8752aa 100644 --- a/forge/ee/db/views/GitToken.js +++ b/forge/ee/db/views/GitToken.js @@ -3,7 +3,8 @@ module.exports = { const result = token.toJSON() const filtered = { id: result.hashid, - name: result.name + name: result.name, + type: result.type // Do not include the token value in the response } return filtered diff --git a/forge/ee/lib/gitops/backends/azure.js b/forge/ee/lib/gitops/backends/azure.js new file mode 100644 index 0000000000..f9e8139f98 --- /dev/null +++ b/forge/ee/lib/gitops/backends/azure.js @@ -0,0 +1,193 @@ +const { exec } = require('node:child_process') +const { existsSync } = require('node:fs') +const fs = require('node:fs/promises') +const os = require('node:os') +const path = require('node:path') +const { promisify } = require('node:util') +const execPromised = promisify(exec) + +const axios = require('axios') + +const { encryptValue, decryptValue } = require('../../../../db/utils') + +const { cloneRepository } = require('./utils') + +module.exports.init = async function (app) { + /** + * Push a snapshot to a git repository + * @param {Object} repoOptions + * @param {String} repoOptions.token + * @param {String} repoOptions.url + * @param {String} repoOptions.branch + * @param {Object} snapshot + * @param {Object} options + * @param {Object} options.sourceObject what produced the snapshot + * @param {Object} options.user who triggered the pipeline + * @param {Object} options.pipeline details of the pipeline + */ + async function pushToRepository (repoOptions, snapshot, options) { + let workingDir + try { + const token = repoOptions.token + const branch = repoOptions.branch || 'main' + if (!/^https:\/\/dev.azure.com/i.test(repoOptions.url)) { + throw new Error('Only Azure repositories are supported') + } + const url = new URL(repoOptions.url) + url.username = token + + const match = /^https:\/\/dev.azure.com\/(?.+)\/_git\/.+$/.exec(repoOptions.url) + const orgName = match.groups?.org + + // 2. get user details so we can properly attribute the commit + let userDetails = {} + if (orgName) { + const userDetailsURL = `https://dev.azure.com/${orgName}/_apis/connectionData` + try { + userDetails = await axios.get(userDetailsURL, { + auth: { + username: '', + password: token + } + }) + } catch (err) { + const result = new Error('Invalid git token') + result.code = 'invalid_token' + result.cause = err + throw result + } + } + + const userGitName = userDetails.data?.authenticatedUser?.customDisplayName || userDetails.data?.authenticatedUser?.providerDisplayName || 'FlowFuse' + const userGitEmail = userDetails.data?.authenticatedUser?.properties?.Account.$value || `flowfuse@${app.config.domain || 'example.com'}` + const author = `${userGitName} <${userGitEmail}>`.replace(/"/g, '\\"') + workingDir = await fs.mkdtemp(path.join(os.tmpdir(), 'flowfuse-git-repo-')) + + // 3. clone repo + await cloneRepository(url, branch, workingDir) + + // 4. set username/email + await execPromised('git config user.email "no-reply@flowfuse.com"', { cwd: workingDir }) + await execPromised('git config user.name "FlowFuse"', { cwd: workingDir }) + // For local dev - disable gpg signing in case its set in global config + await execPromised('git config commit.gpgsign false', { cwd: workingDir }) + + // 5. export snapshot + const exportOptions = { + credentialSecret: repoOptions.credentialSecret, + components: { + flows: true, + credentials: true + } + } + const result = await app.db.controllers.Snapshot.exportSnapshot(snapshot, exportOptions) + const snapshotExport = app.db.views.ProjectSnapshot.snapshotExport(result) + if (snapshotExport.settings?.settings?.palette?.npmrc) { + const enc = encryptValue(repoOptions.credentialSecret, snapshotExport.settings.settings?.palette?.npmrc) + snapshotExport.settings.settings.palette.npmrc = { $: enc } + } + const snapshotFile = path.join(workingDir, repoOptions.path || 'snapshot.json').replace(/"/g, '') + await fs.writeFile(snapshotFile, JSON.stringify(snapshotExport, null, 4)) + + // 6. stage file + await execPromised(`git add "${snapshotFile}"`, { cwd: workingDir }) + + // 7. commit + await execPromised(`git commit -m "Update snapshot\n\nSnapshot updated by FlowFuse Pipeline '${options.pipeline.name.replace(/"/g, '')}', triggered by ${options.user.username.replace(/"/g, '')}" --author="${author}"`, { cwd: workingDir }) + + try { + // 8. push + await execPromised('git push', { cwd: workingDir }) + } catch (err) { + const output = err.stdout + err.stderr + if (/unable to access/.test(output)) { + const result = new Error('Permission denied') + result.code = 'invalid_token' + result.cause = err + throw result + } + let error + const m = /fatal: (.*)/.exec(output) + if (m) { + error = new Error('Failed to push repository: ' + m[1]) + } else { + error = Error('Failed to push repository') + } + error.cause = err + throw error + } + } finally { + if (workingDir) { + try { + await fs.rm(workingDir, { recursive: true, force: true }) + } catch (err) {} + } + } + } + + /** + * Push a snapshot to a git repository + * @param {Object} repoOptions + * @param {String} repoOptions.token + * @param {String} repoOptions.url + * @param {String} repoOptions.branch + */ + async function pullFromRepository (repoOptions) { + let workingDir + try { + const token = repoOptions.token + const branch = repoOptions.branch || 'main' + if (!/^https:\/\/dev.azure.com/i.test(repoOptions.url)) { + throw new Error('Only Azure repositories are supported') + } + const url = new URL(repoOptions.url) + url.username = token + + workingDir = await fs.mkdtemp(path.join(os.tmpdir(), 'flowfuse-git-repo-')) + + // 3. clone repo + await cloneRepository(url, branch, workingDir) + + const snapshotFile = path.join(workingDir, repoOptions.path || 'snapshot.json').replace(/"/g, '') + + if (!existsSync(snapshotFile)) { + throw new Error('Snapshot file not found in repository') + } + + try { + const snapshotContent = await fs.readFile(snapshotFile, 'utf8') + const snapshot = JSON.parse(snapshotContent) + if (snapshot.settings?.env) { + const keys = Object.keys(snapshot.settings.env) + keys.forEach((key) => { + const env = snapshot.settings.env[key] + if (env.hidden && env.$) { + // Decrypt the value if it is encrypted + env.value = decryptValue(repoOptions.credentialSecret, env.$) + delete env.$ + } + }) + } + if (snapshot.settings?.settings?.palette?.npmrc) { + const npmrc = snapshot.settings.settings.palette.npmrc + if (typeof npmrc === 'object' && npmrc.$) { + snapshot.settings.settings.palette.npmrc = decryptValue(repoOptions.credentialSecret, npmrc.$) + } + } + return snapshot + } catch (err) { + throw new Error('Failed to read snapshot file: ' + err.message) + } + } finally { + if (workingDir) { + try { + await fs.rm(workingDir, { recursive: true, force: true }) + } catch (err) {} + } + } + } + return { + pushToRepository, + pullFromRepository + } +} diff --git a/forge/ee/lib/gitops/backends/github.js b/forge/ee/lib/gitops/backends/github.js new file mode 100644 index 0000000000..344b87a3d3 --- /dev/null +++ b/forge/ee/lib/gitops/backends/github.js @@ -0,0 +1,190 @@ +const { exec } = require('node:child_process') +const { existsSync } = require('node:fs') +const fs = require('node:fs/promises') +const os = require('node:os') +const path = require('node:path') +const { promisify } = require('node:util') +const execPromised = promisify(exec) + +const axios = require('axios') + +const { encryptValue, decryptValue } = require('../../../../db/utils') + +const { cloneRepository } = require('./utils') + +module.exports.init = async function (app) { + /** + * Push a snapshot to a git repository + * @param {Object} repoOptions + * @param {String} repoOptions.token + * @param {String} repoOptions.url + * @param {String} repoOptions.branch + * @param {Object} snapshot + * @param {Object} options + * @param {Object} options.sourceObject what produced the snapshot + * @param {Object} options.user who triggered the pipeline + * @param {Object} options.pipeline details of the pipeline + */ + async function pushToRepository (repoOptions, snapshot, options) { + let workingDir + try { + const token = repoOptions.token + const branch = repoOptions.branch || 'main' + if (!/^https:\/\/github.com/i.test(repoOptions.url)) { + throw new Error('Only GitHub repositories are supported') + } + const url = new URL(repoOptions.url) + url.username = 'x-access-token' + url.password = token + + // 2. get user details so we can properly attribute the commit + let userDetails + try { + userDetails = await axios.get('https://api.github.com/user', { + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token}`, + 'X-GitHub-Api-Version': '2022-11-28' + } + }) + } catch (err) { + const result = new Error('Invalid git token') + result.code = 'invalid_token' + result.cause = err + throw result + } + + const userGitName = userDetails.data.login + const userGitEmail = `${userDetails.data.id}+${userDetails.data.login}@users.noreply.github.com` + const author = `${userGitName} <${userGitEmail}>`.replace(/"/g, '\\"') + workingDir = await fs.mkdtemp(path.join(os.tmpdir(), 'flowfuse-git-repo-')) + + // 3. clone repo + await cloneRepository(url, branch, workingDir) + + // 4. set username/email + await execPromised('git config user.email "no-reply@flowfuse.com"', { cwd: workingDir }) + await execPromised('git config user.name "FlowFuse"', { cwd: workingDir }) + // For local dev - disable gpg signing in case its set in global config + await execPromised('git config commit.gpgsign false', { cwd: workingDir }) + + // 5. export snapshot + const exportOptions = { + credentialSecret: repoOptions.credentialSecret, + components: { + flows: true, + credentials: true + } + } + const result = await app.db.controllers.Snapshot.exportSnapshot(snapshot, exportOptions) + const snapshotExport = app.db.views.ProjectSnapshot.snapshotExport(result) + if (snapshotExport.settings?.settings?.palette?.npmrc) { + const enc = encryptValue(repoOptions.credentialSecret, snapshotExport.settings.settings?.palette?.npmrc) + snapshotExport.settings.settings.palette.npmrc = { $: enc } + } + const snapshotFile = path.join(workingDir, repoOptions.path || 'snapshot.json').replace(/"/g, '') + await fs.writeFile(snapshotFile, JSON.stringify(snapshotExport, null, 4)) + + // 6. stage file + await execPromised(`git add "${snapshotFile}"`, { cwd: workingDir }) + + // 7. commit + await execPromised(`git commit -m "Update snapshot\n\nSnapshot updated by FlowFuse Pipeline '${options.pipeline.name.replace(/"/g, '')}', triggered by ${options.user.username.replace(/"/g, '')}" --author="${author}"`, { cwd: workingDir }) + + try { + // 8. push + await execPromised('git push', { cwd: workingDir }) + } catch (err) { + const output = err.stdout + err.stderr + if (/unable to access/.test(output)) { + const result = new Error('Permission denied') + result.code = 'invalid_token' + result.cause = err + throw result + } + let error + const m = /fatal: (.*)/.exec(output) + if (m) { + error = new Error('Failed to push repository: ' + m[1]) + } else { + error = Error('Failed to push repository') + } + error.cause = err + throw error + } + } finally { + if (workingDir) { + try { + await fs.rm(workingDir, { recursive: true, force: true }) + } catch (err) {} + } + } + } + + /** + * Push a snapshot to a git repository + * @param {Object} repoOptions + * @param {String} repoOptions.token + * @param {String} repoOptions.url + * @param {String} repoOptions.branch + */ + async function pullFromRepository (repoOptions) { + let workingDir + try { + const token = repoOptions.token + const branch = repoOptions.branch || 'main' + if (!/^https:\/\/github.com/i.test(repoOptions.url)) { + throw new Error('Only GitHub repositories are supported') + } + const url = new URL(repoOptions.url) + url.username = 'x-access-token' + url.password = token + + workingDir = await fs.mkdtemp(path.join(os.tmpdir(), 'flowfuse-git-repo-')) + + // 3. clone repo + await cloneRepository(url, branch, workingDir) + + const snapshotFile = path.join(workingDir, repoOptions.path || 'snapshot.json').replace(/"/g, '') + + if (!existsSync(snapshotFile)) { + throw new Error('Snapshot file not found in repository') + } + + try { + const snapshotContent = await fs.readFile(snapshotFile, 'utf8') + const snapshot = JSON.parse(snapshotContent) + if (snapshot.settings?.env) { + const keys = Object.keys(snapshot.settings.env) + keys.forEach((key) => { + const env = snapshot.settings.env[key] + if (env.hidden && env.$) { + // Decrypt the value if it is encrypted + env.value = decryptValue(repoOptions.credentialSecret, env.$) + delete env.$ + } + }) + } + if (snapshot.settings?.settings?.palette?.npmrc) { + const npmrc = snapshot.settings.settings.palette.npmrc + if (typeof npmrc === 'object' && npmrc.$) { + snapshot.settings.settings.palette.npmrc = decryptValue(repoOptions.credentialSecret, npmrc.$) + } + } + return snapshot + } catch (err) { + throw new Error('Failed to read snapshot file: ' + err.message) + } + } finally { + if (workingDir) { + try { + await fs.rm(workingDir, { recursive: true, force: true }) + } catch (err) {} + } + } + } + return { + pushToRepository, + pullFromRepository + } +} diff --git a/forge/ee/lib/gitops/backends/utils.js b/forge/ee/lib/gitops/backends/utils.js new file mode 100644 index 0000000000..7b069317f5 --- /dev/null +++ b/forge/ee/lib/gitops/backends/utils.js @@ -0,0 +1,38 @@ +const { exec } = require('node:child_process') +const { promisify } = require('node:util') +const execPromised = promisify(exec) + +async function cloneRepository (url, branch, workingDir) { + try { + await execPromised(`git clone -b ${branch} --depth 1 --single-branch ${url.toString()} .`, { cwd: workingDir }) + } catch (err) { + const output = err.stdout + err.stderr + // Token does not have access to clone the repo + if (/unable to access/.test(output)) { + const result = new Error('Permission denied') + result.code = 'invalid_token' + result.cause = err + throw result + } + // Remote branch does not exist + if (/Could not find remote branch|Remote branch .+ not found/.test(output)) { + const result = new Error('Branch not found') + result.code = 'invalid_branch' + throw result + } + let error + // Fallback - try to extract the 'fatal' line from the output + const m = /fatal: (.*)/.exec(output) + if (m) { + error = new Error('Failed to clone repository: ' + m[1]) + } else { + error = new Error('Failed to clone repository') + } + error.cause = err + throw error + } +} + +module.exports = { + cloneRepository +} diff --git a/forge/ee/lib/gitops/index.js b/forge/ee/lib/gitops/index.js index b1d4602202..1850221528 100644 --- a/forge/ee/lib/gitops/index.js +++ b/forge/ee/lib/gitops/index.js @@ -1,46 +1,7 @@ const { exec } = require('node:child_process') -const { existsSync } = require('node:fs') -const fs = require('node:fs/promises') -const os = require('node:os') -const path = require('node:path') const { promisify } = require('node:util') const execPromised = promisify(exec) -const axios = require('axios') - -const { encryptValue, decryptValue } = require('../../../db/utils') - -async function cloneRepository (url, branch, workingDir) { - try { - await execPromised(`git clone -b ${branch} --depth 1 --single-branch ${url.toString()} .`, { cwd: workingDir }) - } catch (err) { - const output = err.stdout + err.stderr - // Token does not have access to clone the repo - if (/unable to access/.test(output)) { - const result = new Error('Permission denied') - result.code = 'invalid_token' - result.cause = err - throw result - } - // Remote branch does not exist - if (/Could not find remote branch|Remote branch .+ not found/.test(output)) { - const result = new Error('Branch not found') - result.code = 'invalid_branch' - throw result - } - let error - // Fallback - try to extract the 'fatal' line from the output - const m = /fatal: (.*)/.exec(output) - if (m) { - error = new Error('Failed to clone repository: ' + m[1]) - } else { - error = new Error('Failed to clone repository') - } - error.cause = err - throw error - } -} - module.exports.init = async function (app) { // Check if git is installed try { @@ -51,6 +12,10 @@ module.exports.init = async function (app) { return } + // load backends + const github = await require('./backends/github').init(app) + const azure = await require('./backends/azure').init(app) + // Set the git feature flag app.config.features.register('gitIntegration', true, true) @@ -67,98 +32,10 @@ module.exports.init = async function (app) { * @param {Object} options.pipeline details of the pipeline */ async function pushToRepository (repoOptions, snapshot, options) { - let workingDir - try { - const token = repoOptions.token - const branch = repoOptions.branch || 'main' - if (!/^https:\/\/github.com/i.test(repoOptions.url)) { - throw new Error('Only GitHub repositories are supported') - } - const url = new URL(repoOptions.url) - url.username = 'x-access-token' - url.password = token - - // 2. get user details so we can properly attribute the commit - let userDetails - try { - userDetails = await axios.get('https://api.github.com/user', { - headers: { - Accept: 'application/vnd.github+json', - Authorization: `Bearer ${token}`, - 'X-GitHub-Api-Version': '2022-11-28' - } - }) - } catch (err) { - const result = new Error('Invalid git token') - result.code = 'invalid_token' - result.cause = err - throw result - } - - const userGitName = userDetails.data.login - const userGitEmail = `${userDetails.data.id}+${userDetails.data.login}@users.noreply.github.com` - const author = `${userGitName} <${userGitEmail}>`.replace(/"/g, '\\"') - workingDir = await fs.mkdtemp(path.join(os.tmpdir(), 'flowfuse-git-repo-')) - - // 3. clone repo - await cloneRepository(url, branch, workingDir) - - // 4. set username/email - await execPromised('git config user.email "no-reply@flowfuse.com"', { cwd: workingDir }) - await execPromised('git config user.name "FlowFuse"', { cwd: workingDir }) - // For local dev - disable gpg signing in case its set in global config - await execPromised('git config commit.gpgsign false', { cwd: workingDir }) - - // 5. export snapshot - const exportOptions = { - credentialSecret: repoOptions.credentialSecret, - components: { - flows: true, - credentials: true - } - } - const result = await app.db.controllers.Snapshot.exportSnapshot(snapshot, exportOptions) - const snapshotExport = app.db.views.ProjectSnapshot.snapshotExport(result) - if (snapshotExport.settings?.settings?.palette?.npmrc) { - const enc = encryptValue(repoOptions.credentialSecret, snapshotExport.settings.settings?.palette?.npmrc) - snapshotExport.settings.settings.palette.npmrc = { $: enc } - } - const snapshotFile = path.join(workingDir, repoOptions.path || 'snapshot.json').replace(/"/g, '') - await fs.writeFile(snapshotFile, JSON.stringify(snapshotExport, null, 4)) - - // 6. stage file - await execPromised(`git add "${snapshotFile}"`, { cwd: workingDir }) - - // 7. commit - await execPromised(`git commit -m "Update snapshot\n\nSnapshot updated by FlowFuse Pipeline '${options.pipeline.name.replace(/"/g, '')}', triggered by ${options.user.username.replace(/"/g, '')}" --author="${author}"`, { cwd: workingDir }) - - try { - // 8. push - await execPromised('git push', { cwd: workingDir }) - } catch (err) { - const output = err.stdout + err.stderr - if (/unable to access/.test(output)) { - const result = new Error('Permission denied') - result.code = 'invalid_token' - result.cause = err - throw result - } - let error - const m = /fatal: (.*)/.exec(output) - if (m) { - error = new Error('Failed to push repository: ' + m[1]) - } else { - error = Error('Failed to push repository') - } - error.cause = err - throw error - } - } finally { - if (workingDir) { - try { - await fs.rm(workingDir, { recursive: true, force: true }) - } catch (err) {} - } + if (repoOptions.tokenType === 'github') { + await github.pushToRepository(repoOptions, snapshot, options) + } else if (repoOptions.tokenType === 'azure') { + await azure.pushToRepository(repoOptions, snapshot, options) } } @@ -170,60 +47,13 @@ module.exports.init = async function (app) { * @param {String} repoOptions.branch */ async function pullFromRepository (repoOptions) { - let workingDir - try { - const token = repoOptions.token - const branch = repoOptions.branch || 'main' - if (!/^https:\/\/github.com/i.test(repoOptions.url)) { - throw new Error('Only GitHub repositories are supported') - } - const url = new URL(repoOptions.url) - url.username = 'x-access-token' - url.password = token - - workingDir = await fs.mkdtemp(path.join(os.tmpdir(), 'flowfuse-git-repo-')) - - // 3. clone repo - await cloneRepository(url, branch, workingDir) - - const snapshotFile = path.join(workingDir, repoOptions.path || 'snapshot.json').replace(/"/g, '') - - if (!existsSync(snapshotFile)) { - throw new Error('Snapshot file not found in repository') - } - - try { - const snapshotContent = await fs.readFile(snapshotFile, 'utf8') - const snapshot = JSON.parse(snapshotContent) - if (snapshot.settings?.env) { - const keys = Object.keys(snapshot.settings.env) - keys.forEach((key) => { - const env = snapshot.settings.env[key] - if (env.hidden && env.$) { - // Decrypt the value if it is encrypted - env.value = decryptValue(repoOptions.credentialSecret, env.$) - delete env.$ - } - }) - } - if (snapshot.settings?.settings?.palette?.npmrc) { - const npmrc = snapshot.settings.settings.palette.npmrc - if (typeof npmrc === 'object' && npmrc.$) { - snapshot.settings.settings.palette.npmrc = decryptValue(repoOptions.credentialSecret, npmrc.$) - } - } - return snapshot - } catch (err) { - throw new Error('Failed to read snapshot file: ' + err.message) - } - } finally { - if (workingDir) { - try { - await fs.rm(workingDir, { recursive: true, force: true }) - } catch (err) {} - } + if (repoOptions.tokenType === 'github') { + return github.pullFromRepository(repoOptions) + } else if (repoOptions.tokenType === 'azure') { + return azure.pullFromRepository(repoOptions) } } + return { pushToRepository, pullFromRepository diff --git a/forge/ee/routes/gitops/index.js b/forge/ee/routes/gitops/index.js index bc307d91df..931047384f 100644 --- a/forge/ee/routes/gitops/index.js +++ b/forge/ee/routes/gitops/index.js @@ -48,7 +48,8 @@ module.exports = async function (app) { const token = await app.db.models.GitToken.create({ name: body.name, token: body.token, - TeamId: request.team.id + TeamId: request.team.id, + type: body.type || 'github' }) // TODO: audit log // await app.auditLog.Project.project.httpToken.created(request.session.User, null, request.project, body) diff --git a/frontend/src/assets/icons/azure.svg b/frontend/src/assets/icons/azure.svg new file mode 100644 index 0000000000..cb36c1d5b8 --- /dev/null +++ b/frontend/src/assets/icons/azure.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/assets/icons/github.svg b/frontend/src/assets/icons/github.svg new file mode 100644 index 0000000000..109ee7af8b --- /dev/null +++ b/frontend/src/assets/icons/github.svg @@ -0,0 +1 @@ + diff --git a/frontend/src/components/icons/Git.js b/frontend/src/components/icons/Git.js index 5c5ca7154b..393e110cfb 100644 --- a/frontend/src/components/icons/Git.js +++ b/frontend/src/components/icons/Git.js @@ -1,17 +1,27 @@ const { createVNode: _createVNode, openBlock: _openBlock, createBlock: _createBlock } = require('vue') -module.exports = function render (_ctx, _cache) { - const path1 = _createVNode('path', { - fill: 'currentFill', - d: 'M251.172 116.594L139.4 4.828c-6.433-6.437-16.873-6.437-23.314 0l-23.21 23.21 29.443 29.443c6.842-2.312 14.688-.761 20.142 4.693 5.48 5.489 7.02 13.402 4.652 20.266l28.375 28.376c6.865-2.365 14.786-.835 20.269 4.657 7.663 7.66 7.663 20.075 0 27.74-7.665 7.666-20.08 7.666-27.749 0-5.764-5.77-7.188-14.235-4.27-21.336l-26.462-26.462-.003 69.637a19.82 19.82 0 0 1 5.188 3.71c7.663 7.66 7.663 20.076 0 27.747-7.665 7.662-20.086 7.662-27.74 0-7.663-7.671-7.663-20.086 0-27.746a19.654 19.654 0 0 1 6.421-4.281V94.196a19.378 19.378 0 0 1-6.421-4.281c-5.806-5.798-7.202-14.317-4.227-21.446L81.47 39.442l-76.64 76.635c-6.44 6.443-6.44 16.884 0 23.322l111.774 111.768c6.435 6.438 16.873 6.438 23.316 0l111.251-111.249c6.438-6.44 6.438-16.887 0-23.324' - }) +module.exports = { + props: { + type: { + required: true, + type: String + } - return (_openBlock(), _createBlock('svg', { - xmlns: 'http://www.w3.org/2000/svg', - viewBox: '0 0 256 256', - fill: 'currentColor', - 'aria-hidden': 'true' - }, [ - path1 - ])) + }, + render: function (_ctx, _cache) { + const path1 = _createVNode('path', { + fill: 'currentFill', + d: 'M251.172 116.594L139.4 4.828c-6.433-6.437-16.873-6.437-23.314 0l-23.21 23.21 29.443 29.443c6.842-2.312 14.688-.761 20.142 4.693 5.48 5.489 7.02 13.402 4.652 20.266l28.375 28.376c6.865-2.365 14.786-.835 20.269 4.657 7.663 7.66 7.663 20.075 0 27.74-7.665 7.666-20.08 7.666-27.749 0-5.764-5.77-7.188-14.235-4.27-21.336l-26.462-26.462-.003 69.637a19.82 19.82 0 0 1 5.188 3.71c7.663 7.66 7.663 20.076 0 27.747-7.665 7.662-20.086 7.662-27.74 0-7.663-7.671-7.663-20.086 0-27.746a19.654 19.654 0 0 1 6.421-4.281V94.196a19.378 19.378 0 0 1-6.421-4.281c-5.806-5.798-7.202-14.317-4.227-21.446L81.47 39.442l-76.64 76.635c-6.44 6.443-6.44 16.884 0 23.322l111.774 111.768c6.435 6.438 16.873 6.438 23.316 0l111.251-111.249c6.438-6.44 6.438-16.887 0-23.324' + }) + + return (_openBlock(), _createBlock('svg', { + xmlns: 'http://www.w3.org/2000/svg', + viewBox: '0 0 256 256', + fill: 'currentColor', + 'aria-hidden': 'true' + }, [ + path1 + ] + )) + } } diff --git a/frontend/src/components/pipelines/Stage.vue b/frontend/src/components/pipelines/Stage.vue index 3d396bb9c6..1416d4d250 100644 --- a/frontend/src/components/pipelines/Stage.vue +++ b/frontend/src/components/pipelines/Stage.vue @@ -90,11 +90,11 @@
- +
- - {{ stage.gitRepo?.url.replace("https://github.com/","") }} + + {{ stage.gitRepo?.url.replace("https://github.com/","").replace("https://dev.azure.com/","") }}
@@ -293,6 +293,16 @@ export default { return 'Device in Dev Mode' } return '' + }, + gitRepoLink () { + if (this.stage?.gitRepo?.url.startsWith('https://github.com')) { + return `${this.stage.gitRepo.url}/tree/${this.stage.gitRepo.branch || 'main'}` + } else if (this.stage?.gitRepo?.url.startsWith('https://dev.azure.com')) { + const regex = /^https:\/\/dev.azure.com\/(?.+)\/_git\/(?.+)$/ + const match = regex.exec(this.stage.gitRepo.url) + return `https://dev.azure.com/${match.groups.org}/${match.groups.repo}/_git/${match.groups.repo}?path=%2F&version=GB${this.stage.gitRepo.branch || 'main'}` + } + return `${this.stage.gitRepo.url}/tree/${this.stage.gitRepo.branch || 'main'}` } }, created () { diff --git a/frontend/src/components/variant-selector/CascadingSelector.vue b/frontend/src/components/variant-selector/CascadingSelector.vue index 7c9ca61a96..5ec3bcfe9c 100644 --- a/frontend/src/components/variant-selector/CascadingSelector.vue +++ b/frontend/src/components/variant-selector/CascadingSelector.vue @@ -28,11 +28,16 @@ export default { node: { type: Object, required: true + }, + modelValue: { + type: String, + default: null } }, + emits: ['update:modelValue'], data () { return { - selectedId: this.node.children?.[0]?.id ?? null + selectedId: this.modelValue ?? this.node.children?.[0]?.id ?? null } }, computed: { @@ -43,6 +48,11 @@ export default { if (!this.selectedId) return null return (this.node.children ?? []).find(c => c.id === this.selectedId) ?? null } + }, + watch: { + selectedId (val) { + this.$emit('update:modelValue', val) + } } } diff --git a/frontend/src/components/variant-selector/OptionTileSelector.vue b/frontend/src/components/variant-selector/OptionTileSelector.vue index 9fe4298148..54d1fe18bc 100644 --- a/frontend/src/components/variant-selector/OptionTileSelector.vue +++ b/frontend/src/components/variant-selector/OptionTileSelector.vue @@ -12,7 +12,7 @@ :class="{ 'border-blue-600': modelValue === option.id }" @click="$emit('update:modelValue', option.id)" > - + {{ option.label }} diff --git a/frontend/src/pages/application/PipelineStage/form.vue b/frontend/src/pages/application/PipelineStage/form.vue index 69e3525c5d..6182b5eadf 100644 --- a/frontend/src/pages/application/PipelineStage/form.vue +++ b/frontend/src/pages/application/PipelineStage/form.vue @@ -144,7 +144,7 @@ Choose Git Token { return { label: token.name, - value: token.id + value: token.id, + type: token.type } }) } diff --git a/frontend/src/pages/team/Pipelines/components/TeamPipelineStage.vue b/frontend/src/pages/team/Pipelines/components/TeamPipelineStage.vue index 949f281d5e..f7ed9af989 100644 --- a/frontend/src/pages/team/Pipelines/components/TeamPipelineStage.vue +++ b/frontend/src/pages/team/Pipelines/components/TeamPipelineStage.vue @@ -7,7 +7,7 @@ - + {{ targetName }} @@ -51,6 +51,16 @@ export default { isGitRepoStage () { return Object.hasOwnProperty.call(this.stage, 'gitRepo') }, + gitType () { + if (this.isGitRepoStage) { + if (this.stage.gitRepo.url.startsWith('https://github.com/')) { + return 'github' + } else if (this.stage.gitRepo.url.startsWith('https://dev.azure.com/')) { + return 'azure' + } + } + return '' + }, targetName () { switch (true) { @@ -61,7 +71,7 @@ export default { case this.isDeviceGroupsStage: return this.stage.deviceGroups[0]?.name case this.isGitRepoStage: - return this.stage.gitRepo?.url.replace('https://github.com/', '') + ( + return this.stage.gitRepo?.url.replace('https://github.com/', '').replace('https://dev.azure.com/', '') + ( (this.stage.gitRepo?.branch && this.stage.gitRepo?.branch !== 'main' && this.stage.gitRepo?.branch !== 'master') ? `@${this.stage.gitRepo.branch}` : '' diff --git a/frontend/src/pages/team/Settings/Integrations.vue b/frontend/src/pages/team/Settings/Integrations.vue index 8d05865452..18b9b29e28 100644 --- a/frontend/src/pages/team/Settings/Integrations.vue +++ b/frontend/src/pages/team/Settings/Integrations.vue @@ -1,7 +1,7 @@