diff --git a/app/Http/Controllers/BuildController.php b/app/Http/Controllers/BuildController.php
index ff106927ff..9a56c2a618 100644
--- a/app/Http/Controllers/BuildController.php
+++ b/app/Http/Controllers/BuildController.php
@@ -116,7 +116,10 @@ public function update(int $build_id): View
{
$this->setBuildById($build_id);
- return $this->vue('build-update', 'Files Updated');
+ return $this->vue('build-update', 'Files Updated', [
+ 'repository-type' => $this->project->CvsViewerType,
+ 'repository-url' => $this->project->CvsUrl,
+ ]);
}
public function tests(int $build_id): View
diff --git a/resources/js/vue/components/BuildUpdate.vue b/resources/js/vue/components/BuildUpdate.vue
index 4656b66d51..049cbac769 100644
--- a/resources/js/vue/components/BuildUpdate.vue
+++ b/resources/js/vue/components/BuildUpdate.vue
@@ -15,7 +15,7 @@
{{ cdash.update.revision }}
@@ -27,7 +27,7 @@
{{ cdash.update.priorrevision }}
@@ -131,11 +131,24 @@ import LoadingIndicator from './shared/LoadingIndicator.vue';
import CodeBox from './shared/CodeBox.vue';
import {faChevronDown, faChevronRight} from '@fortawesome/free-solid-svg-icons';
import {FontAwesomeIcon} from '@fortawesome/vue-fontawesome';
+import {getRepository} from './shared/RepositoryIntegrations';
export default {
name: 'BuildUpdate',
components: {FontAwesomeIcon, CodeBox, LoadingIndicator, BuildSummaryCard},
+ props: {
+ repositoryType: {
+ type: String,
+ required: true,
+ },
+
+ repositoryUrl: {
+ type: String,
+ required: true,
+ },
+ },
+
data() {
return {
// API results.
@@ -167,6 +180,10 @@ export default {
faChevronRight,
};
},
+
+ repository() {
+ return getRepository(this.repositoryType, this.repositoryUrl);
+ },
},
async mounted() {
diff --git a/resources/js/vue/components/shared/RepositoryIntegrations.js b/resources/js/vue/components/shared/RepositoryIntegrations.js
new file mode 100644
index 0000000000..1f685df0ac
--- /dev/null
+++ b/resources/js/vue/components/shared/RepositoryIntegrations.js
@@ -0,0 +1,83 @@
+/**
+ * Combine URL components, ensuring that trailing slashes are stripped before concatenation.
+ *
+ * @param {...String} components
+ */
+function makeUrlFromComponents(...components) {
+ return components.map(part => part.replace(/\/+$/, '')).join('/');
+}
+
+export class Repository {
+ constructor(repositoryUrl) {
+ this.repositoryUrl = repositoryUrl;
+ }
+
+ /**
+ * @param {String} commit
+ * @return String
+ */
+ getCommitUrl(commit) { // eslint-disable-line no-unused-vars
+ throw new Error('Method not implemented for abstract Repository class.');
+ }
+
+ /**
+ * @param {String} commit1
+ * @param {String} commit2
+ * @return String
+ */
+ getComparisonUrl(commit1, commit2) { // eslint-disable-line no-unused-vars
+ throw new Error('Method not implemented for abstract Repository class.');
+ }
+
+ /**
+ * @param {String} commit
+ * @param {String} path
+ * @return String
+ */
+ getFileUrl(commit, path) { // eslint-disable-line no-unused-vars
+ throw new Error('Method not implemented for abstract Repository class.');
+ }
+}
+
+export class GitHub extends Repository {
+ getCommitUrl(commit) {
+ return makeUrlFromComponents(this.repositoryUrl, 'commit', commit);
+ }
+
+ getComparisonUrl(commit1, commit2) {
+ return makeUrlFromComponents(this.repositoryUrl, 'compare', `${commit1}...${commit2}`);
+ }
+
+ getFileUrl(commit, path) {
+ return makeUrlFromComponents(this.repositoryUrl, 'blob', commit, path);
+ }
+}
+
+export class GitLab extends Repository {
+ getCommitUrl(commit) {
+ return makeUrlFromComponents(this.repositoryUrl, '-', 'commit', commit);
+ }
+
+ getComparisonUrl(commit1, commit2) {
+ return makeUrlFromComponents(this.repositoryUrl, '-', 'compare', `${commit1}...${commit2}`);
+ }
+ getFileUrl(commit, path) {
+ return makeUrlFromComponents(this.repositoryUrl, '-', 'blob', commit, path);
+ }
+}
+
+/**
+ * @param {String} repositoryType
+ * @param {String} repositoryUrl
+ * @return ?Repository
+ */
+export function getRepository(repositoryType, repositoryUrl) {
+ switch (repositoryType.toLowerCase()) {
+ case 'github':
+ return new GitHub(repositoryUrl);
+ case 'gitlab':
+ return new GitLab(repositoryUrl);
+ default:
+ return null;
+ }
+}
diff --git a/tests/Spec/CMakeLists.txt b/tests/Spec/CMakeLists.txt
index d8b3254bfa..a6b2cdc3db 100644
--- a/tests/Spec/CMakeLists.txt
+++ b/tests/Spec/CMakeLists.txt
@@ -1,4 +1,4 @@
-function(add_vue_test TestName)
+function(add_jest_test TestName)
add_test(
NAME "Spec/${TestName}"
COMMAND "node_modules/.bin/jest" "tests/Spec/${TestName}.spec.js"
@@ -6,7 +6,8 @@ function(add_vue_test TestName)
)
endfunction()
-add_vue_test(build-summary)
-add_vue_test(edit-project)
-add_vue_test(manage-measurements)
-add_vue_test(test-details)
+add_jest_test(build-summary)
+add_jest_test(edit-project)
+add_jest_test(manage-measurements)
+add_jest_test(test-details)
+add_jest_test(repository-integrations)
diff --git a/tests/Spec/repository-integrations.spec.js b/tests/Spec/repository-integrations.spec.js
new file mode 100644
index 0000000000..2d29883d94
--- /dev/null
+++ b/tests/Spec/repository-integrations.spec.js
@@ -0,0 +1,104 @@
+import {
+ getRepository,
+ GitHub,
+ GitLab,
+ Repository,
+} from '../../resources/js/vue/components/shared/RepositoryIntegrations';
+
+describe('RepositoryIntegrations', () => {
+ describe('getRepository', () => {
+ it('returns a GitHub instance for "github" type', () => {
+ const repo = getRepository('github', 'https://github.com/foo/bar');
+ expect(repo).toBeInstanceOf(GitHub);
+ expect(repo.repositoryUrl).toBe('https://github.com/foo/bar');
+ });
+
+ it('returns a GitLab instance for "gitlab" type', () => {
+ const repo = getRepository('gitlab', 'https://gitlab.com/foo/bar');
+ expect(repo).toBeInstanceOf(GitLab);
+ expect(repo.repositoryUrl).toBe('https://gitlab.com/foo/bar');
+ });
+
+ it('is case insensitive for repository type', () => {
+ const repo = getRepository('GitHub', 'https://github.com/foo/bar');
+ expect(repo).toBeInstanceOf(GitHub);
+ });
+
+ it('returns null for unknown repository types', () => {
+ const repo = getRepository('bitbucket', 'https://bitbucket.org/foo/bar');
+ expect(repo).toBeNull();
+ });
+ });
+
+ describe('GitHub', () => {
+ const repoUrl = 'https://github.com/foo/bar';
+ const repo = new GitHub(repoUrl);
+
+ it('generates correct commit URL', () => {
+ const commit = 'abcdef123456';
+ expect(repo.getCommitUrl(commit)).toBe(`${repoUrl}/commit/${commit}`);
+ });
+
+ it('generates correct comparison URL', () => {
+ const commit1 = 'abc';
+ const commit2 = 'def';
+ expect(repo.getComparisonUrl(commit1, commit2)).toBe(`${repoUrl}/compare/${commit1}...${commit2}`);
+ });
+
+ it('generates correct file URL', () => {
+ const commit = 'abcdef123456';
+ const path = 'src/main.cpp';
+ expect(repo.getFileUrl(commit, path)).toBe(`${repoUrl}/blob/${commit}/${path}`);
+ });
+
+ it('handles trailing slashes in repository URL', () => {
+ const repoWithSlash = new GitHub('https://github.com/foo/bar/');
+ const commit = '123';
+ expect(repoWithSlash.getCommitUrl(commit)).toBe('https://github.com/foo/bar/commit/123');
+ });
+ });
+
+ describe('GitLab', () => {
+ const repoUrl = 'https://gitlab.com/foo/bar';
+ const repo = new GitLab(repoUrl);
+
+ it('generates correct commit URL', () => {
+ const commit = 'abcdef123456';
+ expect(repo.getCommitUrl(commit)).toBe(`${repoUrl}/-/commit/${commit}`);
+ });
+
+ it('generates correct comparison URL', () => {
+ const commit1 = 'abc';
+ const commit2 = 'def';
+ expect(repo.getComparisonUrl(commit1, commit2)).toBe(`${repoUrl}/-/compare/${commit1}...${commit2}`);
+ });
+
+ it('generates correct file URL', () => {
+ const commit = 'abcdef123456';
+ const path = 'src/main.cpp';
+ expect(repo.getFileUrl(commit, path)).toBe(`${repoUrl}/-/blob/${commit}/${path}`);
+ });
+
+ it('handles trailing slashes in repository URL', () => {
+ const repoWithSlash = new GitLab('https://gitlab.com/foo/bar/');
+ const commit = '123';
+ expect(repoWithSlash.getCommitUrl(commit)).toBe('https://gitlab.com/foo/bar/-/commit/123');
+ });
+ });
+
+ describe('Repository (Abstract)', () => {
+ const repo = new Repository('https://example.com');
+
+ it('throws error for unimplemented getCommitUrl', () => {
+ expect(() => repo.getCommitUrl('123')).toThrow('Method not implemented for abstract Repository class.');
+ });
+
+ it('throws error for unimplemented getComparisonUrl', () => {
+ expect(() => repo.getComparisonUrl('123', '456')).toThrow('Method not implemented for abstract Repository class.');
+ });
+
+ it('throws error for unimplemented getFileUrl', () => {
+ expect(() => repo.getFileUrl('123', 'file.txt')).toThrow('Method not implemented for abstract Repository class.');
+ });
+ });
+});