Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,8 @@ TESTPLAN_PATH=./custom-testplan.json TESTPLAN_NAME=backend-tests npm test

# Force UI test execution
ENABLE_UI_TESTS=true npm test
# Run import template tests (supports all Git providers)
npm run test:import

# View test report
npm run test:report
Expand Down Expand Up @@ -387,6 +389,10 @@ TESTPLAN_NAME=github-tests npm test
# Run UI tests
npm run test:ui

# Run import template tests (supports all Git providers)
npm run test:import


# View test report
npm run test:report

Expand Down
18 changes: 16 additions & 2 deletions integration-tests/config/testplan.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"testPlans": [
{
"name": "backend-tests",
"name": "e2e-tests",
"templates": ["go"],
"tssc": [{
"git": "github",
Expand Down Expand Up @@ -45,7 +45,21 @@
"tpa": "remote",
"acs": "remote"
}],
"tests": ["full_workflow.test.ts"]
"tests": ["tssc/full_workflow.test.ts"]
},
{
"name": "import-tests",
"templates": ["go"],
"tssc": [
{
"git": "github",
"ci": "tekton",
"registry": "quay",
"tpa": "remote",
"acs": "remote"
}
],
"tests": ["templates/import_templates.test.ts"]
}
]
}
2 changes: 1 addition & 1 deletion integration-tests/tasks/tssc-e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ spec:
source .env
# Run tests and capture exit code
TEST_EXIT_CODE=0
FORCE_COLOR=false npm run test:e2e || TEST_EXIT_CODE=$?
FORCE_COLOR=false npm run test:backend || TEST_EXIT_CODE=$?

echo "Test execution completed with exit code: $TEST_EXIT_CODE"

Expand Down
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@
"validate": "npm run type-check && npm run lint && npm run format:check",
"generate-config": "npx ts-node scripts/generateProjectConfig.ts",
"test": "npm run generate-config && playwright test",
"test:e2e": "TESTPLAN_NAME=backend-tests npm run generate-config && playwright test",
"test:e2e": "TESTPLAN_NAME=e2e-tests npm run generate-config && playwright test",
"test:import": "TESTPLAN_NAME=import-tests npm run generate-config && playwright test",
"test:backend": "TESTPLAN_NAME=e2e-tests,import-tests npm run generate-config && playwright test",
"test:ui": "TESTPLAN_NAME=ui-tests playwright test",
"test:ui-interactive": "TESTPLAN_NAME=ui-tests playwright test --ui",
"test:all": "TESTPLAN_NAME=backend-tests npm run generate-config && playwright test && TESTPLAN_NAME=ui-tests playwright test",
"test:all": "TESTPLAN_NAME=e2e-tests,import-tests npm run generate-config && playwright test && TESTPLAN_NAME=ui-tests playwright test",
"test:report": "playwright show-report"
},
"keywords": [
Expand Down
5 changes: 3 additions & 2 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ declare module '@playwright/test' {
// Configuration constants
const DEFAULT_TIMEOUT = 2100000; // 35 minutes

// Environment variable flags to control which tests run
const DEFAULT_WORKERS = 6;
const DEFAULT_UI_TIMEOUT = 60000;

Expand Down Expand Up @@ -83,6 +82,7 @@ try {

let e2eProjects: any[] = [];
let uiProjects: any[] = [];
let importProjects: any[] = [];
let authProjects: any[] = [];

// Create E2E projects (always created for backend tests)
Expand Down Expand Up @@ -187,7 +187,8 @@ try {
allProjects = [
...authProjects,
...e2eProjects,
...uiProjects
...uiProjects,
...importProjects
];

} catch (error) {
Expand Down
122 changes: 121 additions & 1 deletion src/api/bitbucket/services/bitbucket-repository.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import retry from 'async-retry';
import { defaultLogger } from '../../../log/logger';
import { BitbucketHttpClient } from '../http/bitbucket-http.client';
import { BitbucketRepository, BitbucketBranch, BitbucketCommit, BitbucketPaginatedResponse } from '../types/bitbucket.types';
import { BitbucketRepository, BitbucketBranch, BitbucketCommit, BitbucketPaginatedResponse, BitbucketDirectoryEntry } from '../types/bitbucket.types';

export class BitbucketRepositoryService {
constructor(private readonly httpClient: BitbucketHttpClient) {}
Expand Down Expand Up @@ -47,4 +49,122 @@ export class BitbucketRepositoryService {
public async getFileContent(workspace: string, repoSlug: string, filePath: string, ref: string = 'main'): Promise<string> {
return this.httpClient.get<string>(`/repositories/${workspace}/${repoSlug}/src/${ref}/${filePath}`);
}

public async getDirectoryContent(workspace: string, repoSlug: string, path: string, ref: string = 'main'): Promise<BitbucketDirectoryEntry[]> {
// Input validation
if (!workspace || workspace.trim() === '') {
throw new Error('Workspace is required and cannot be empty');
}
if (!repoSlug || repoSlug.trim() === '') {
throw new Error('Repository slug is required and cannot be empty');
}
if (!ref || ref.trim() === '') {
throw new Error('Reference is required and cannot be empty');
}

const trimmedWorkspace = workspace.trim();
const trimmedRepoSlug = repoSlug.trim();
const trimmedPath = path.trim();
const trimmedRef = ref.trim();

try {
const response = await retry(
async () => {
return await this.httpClient.get<BitbucketPaginatedResponse<BitbucketDirectoryEntry>>(
`/repositories/${trimmedWorkspace}/${trimmedRepoSlug}/src/${trimmedRef}/${trimmedPath}`
);
},
{
retries: 3,
minTimeout: 1000,
maxTimeout: 5000,
factor: 2,
onRetry: (error: Error, attempt: number) => {
defaultLogger.warn({
operation: 'getDirectoryContent',
workspace: trimmedWorkspace,
repoSlug: trimmedRepoSlug,
path: trimmedPath,
ref: trimmedRef,
attempt,
error: error.message
}, `Retrying directory content retrieval (attempt ${attempt}/3)`);
}
}
);

// Defensive validation of response
if (!response || !Array.isArray(response.values)) {
defaultLogger.warn({
operation: 'getDirectoryContent',
workspace: trimmedWorkspace,
repoSlug: trimmedRepoSlug,
path: trimmedPath,
ref: trimmedRef,
responseType: typeof response,
hasValues: !!response?.values
}, `Invalid response structure for directory content, returning empty array`);
return [];
}

defaultLogger.info({
operation: 'getDirectoryContent',
workspace: trimmedWorkspace,
repoSlug: trimmedRepoSlug,
path: trimmedPath,
ref: trimmedRef,
itemCount: response.values.length
}, `Successfully retrieved directory content for ${trimmedWorkspace}/${trimmedRepoSlug}/${trimmedPath}`);

return response.values;
} catch (error: any) {
// Handle 404 errors gracefully for idempotent operations
if (error.response?.status === 404 || error.status === 404 || error.message?.includes('404')) {
defaultLogger.info({
operation: 'getDirectoryContent',
workspace: trimmedWorkspace,
repoSlug: trimmedRepoSlug,
path: trimmedPath,
ref: trimmedRef,
status: 'not_found'
}, `Directory content not found for ${trimmedWorkspace}/${trimmedRepoSlug}/${trimmedPath} (404 Not Found)`);
return [];
}
Comment on lines +70 to +132
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Don’t retry directory lookups after a 404.

retry currently wraps the GET without any bail-out, so a missing directory forces three attempts before falling through to the 404 handler that returns an empty array. That slows our Bitbucket workflows and burns rate limit for an idempotent “not found” case. Please pass bail into the retry function and call it when you detect a 404 so we short-circuit immediately.

-      const response = await retry(
-        async () => {
+      const response = await retry(
+        async (bail) => {
           try {
             return await this.httpClient.get<BitbucketPaginatedResponse<BitbucketDirectoryEntry>>(
               `/repositories/${trimmedWorkspace}/${trimmedRepoSlug}/src/${trimmedRef}/${trimmedPath}`
             );
-          } catch (error: any) {
-            throw error;
+          } catch (error: any) {
+            if (error.response?.status === 404 || error.status === 404 || error.message?.includes('404')) {
+              bail(error);
+              return;
+            }
+            throw error;
           }
         },

As per coding guidelines.

Committable suggestion skipped: line range outside the PR's diff.


defaultLogger.error({
operation: 'getDirectoryContent',
workspace: trimmedWorkspace,
repoSlug: trimmedRepoSlug,
path: trimmedPath,
ref: trimmedRef,
error: error.message,
status: error.response?.status || error.status
}, `Failed to get directory content for ${trimmedWorkspace}/${trimmedRepoSlug}/${trimmedPath}`);
throw error;
}
}

public async deleteFile(workspace: string, repoSlug: string, filePath: string, branch: string = 'main', commitMessage: string = 'Delete file'): Promise<void> {
try {
// Bitbucket API requires a commit with file deletion
const commitData = {
message: commitMessage,
branch: branch,
files: {
[filePath]: null // null value indicates file deletion
}
};

await this.httpClient.post(`/repositories/${workspace}/${repoSlug}/src`, commitData, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
});

console.log(`Successfully deleted file ${filePath} from ${workspace}/${repoSlug}`);
} catch (error) {
console.error(`Failed to delete file ${filePath} from ${workspace}/${repoSlug}:`, error);
throw error;
}
}
Comment on lines +147 to +169
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Critical: Form-urlencoded payload formatting is incorrect.

The deleteFile method has a critical implementation issue:

The Content-Type header is set to application/x-www-form-urlencoded, but the payload is sent as a plain JavaScript object. Form-urlencoded requires a URL-encoded string format (key1=value1&key2=value2). Sending a nested object with this content type will likely fail or produce unexpected results.

Additionally, per coding guidelines for src/api/**, implement retry mechanisms, timeouts, and circuit breaker patterns for API calls.

Verify the correct Bitbucket API format for file deletion.

Before applying fixes, verify the actual Bitbucket API requirements for file deletion. The current structure suggests form-urlencoded, but the nested files object is incompatible with that format.

Run the following script to check Bitbucket API documentation and verify the correct format:

If form-urlencoded is indeed required, the payload must be properly encoded. Consider using a library like qs or native URLSearchParams to encode the data. Alternatively, if Bitbucket accepts JSON for this endpoint, change the Content-Type to application/json.


🏁 Script executed:

#!/bin/bash
# Description: Search for Bitbucket file deletion patterns in the codebase and verify API format

# Search for other Bitbucket API calls with form-urlencoded to understand the pattern
rg -n --type=ts -A 5 -B 5 "application/x-www-form-urlencoded" 

# Search for any existing file deletion implementations
rg -n --type=ts -A 10 "deleteFile|delete.*file" src/api/

Length of output: 8451


Serialize form-urlencoded payload properly
The deleteFile method sets Content-Type: application/x-www-form-urlencoded but sends a raw JS object. Use URLSearchParams or qs.stringify to serialize message, branch and the dynamic files[filePath]=null into key=value&… form (or switch to application/json if supported).

src/api/bitbucket/services/bitbucket-repository.service.ts:72-75

}
9 changes: 9 additions & 0 deletions src/api/bitbucket/types/bitbucket.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,15 @@ export interface BitbucketWebhook {
events: string[];
}

export interface BitbucketDirectoryEntry {
path: string;
type: 'commit_file' | 'commit_directory';
size?: number;
commit?: {
hash: string;
};
}

export interface BitbucketPaginatedResponse<T> {
values: T[];
page?: number;
Expand Down
Loading