From d3b40ae93f8a7512a60e516d02d31d7b18ebfd7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Guti=C3=A9rrez=20de=20Quevedo=20P=C3=A9?= =?UTF-8?q?rez?= Date: Fri, 21 Nov 2025 17:18:55 +0100 Subject: [PATCH 1/4] feat: Add patchset history and diff support with cvsps integration - Add cvsps package to Docker dependencies - Implement patchset retrieval and diff endpoints in API - Add _run_cvsps_command() helper for cvsps integration - Enhance file history parsing with log message extraction - Improve UI with enhanced styling and patchset functionality --- Dockerfile | 4 +- cvs_proxy/app.py | 34 ++++++ cvs_proxy/cvs_client.py | 197 +++++++++++++++++++++++++++++- cvs_proxy/test_cvs_client.py | 12 +- ui/api.js | 19 +++ ui/app.js | 43 +++++++ ui/index.html | 26 ++++ ui/styles.css | 226 ++++++++++++++++++++++++++++++++--- ui/ui.js | 125 +++++++++++++++++-- 9 files changed, 660 insertions(+), 26 deletions(-) diff --git a/Dockerfile b/Dockerfile index da43266..cdec76b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ FROM python:3-bookworm WORKDIR /app # Install runtime dependencies (minimal) -RUN apt update && apt install cvs rsh-client +RUN apt update && apt install cvs rsh-client cvsps COPY . . @@ -26,4 +26,4 @@ ENV CVS_URL="" \ EXPOSE 5000 # Set the entrypoint to run the application with environment variables as command-line arguments -ENTRYPOINT ["sh", "-c", "python -m cvs_proxy.app --cvs-url \"$CVS_URL\" --repo-checkouts \"$REPO_CHECKOUTS\" ${CVS_MODULE:+--cvs-module \"$CVS_MODULE\"} ${BASEPATH:+--basepath \"$BASEPATH\"} --host \"$FLASK_HOST\" --port \"$FLASK_PORT\" ${FLASK_DEBUG:+--debug}"] \ No newline at end of file +ENTRYPOINT ["sh", "-c", "python -m cvs_proxy.app --cvs-url \"$CVS_URL\" --repo-checkouts \"$REPO_CHECKOUTS\" ${CVS_MODULE:+--cvs-module \"$CVS_MODULE\"} ${BASEPATH:+--basepath \"$BASEPATH\"} --host \"$FLASK_HOST\" --port \"$FLASK_PORT\" ${FLASK_DEBUG:+--debug}"] diff --git a/cvs_proxy/app.py b/cvs_proxy/app.py index fdfccb0..31e88f4 100644 --- a/cvs_proxy/app.py +++ b/cvs_proxy/app.py @@ -108,6 +108,40 @@ def get_file_history(): except Exception as e: return jsonify({"error": str(e)}), 500 +@api_bp.route('/api/v1/patchsets', methods=['GET']) +def get_patchsets(): + """ + Get repository patchset history using cvsps + """ + if not cvs_client: + return jsonify({"error": "CVS Client not initialized"}), 500 + + try: + patchsets = cvs_client.get_patchsets() + return jsonify(patchsets) + except Exception as e: + return jsonify({"error": str(e)}), 500 + +@api_bp.route('/api/v1/patchset-diff', methods=['GET']) +def get_patchset_diff(): + """ + Get diff for a specific patchset + Required query param: patchset (patchset number) + """ + if not cvs_client: + return jsonify({"error": "CVS Client not initialized"}), 500 + + patchset = request.args.get('patchset') + + if not patchset: + return jsonify({"error": "Missing patchset parameter"}), 400 + + try: + diff = cvs_client.get_patchset_diff(patchset) + return jsonify({"diff": diff}) + except Exception as e: + return jsonify({"error": str(e)}), 500 + @api_bp.route('/api/v1/file', methods=['GET']) def get_file_content(): """ diff --git a/cvs_proxy/cvs_client.py b/cvs_proxy/cvs_client.py index 357ef36..46dc874 100644 --- a/cvs_proxy/cvs_client.py +++ b/cvs_proxy/cvs_client.py @@ -84,6 +84,52 @@ class CVSClient: # Re-raise to allow caller to handle specific errors raise + def _run_cvsps_command(self, command, cwd=None): + """ + Run a cvsps command (standalone program, not a CVS subcommand) + + :param command: List of cvsps command arguments + :type command: list + :param cwd: Working directory for the command + :type cwd: str, optional + :return: Command output as string + :rtype: str + :raises subprocess.CalledProcessError: If the cvsps command fails + """ + full_command = ['cvsps'] + command + + # Debug printout of the command to be executed + print(f"DEBUG: Executing cvsps command: {' '.join(full_command)}", file=sys.stderr) + print(f"DEBUG: Working directory: {cwd or self.local_repo_path}", file=sys.stderr) + + try: + result = subprocess.run( + full_command, + capture_output=True, + text=True, + check=True, + cwd=cwd or self.local_repo_path, + env={**os.environ, 'CVSROOT': self.repo_url} + ) + + # Debug printout of stdout and stderr + if result.stdout: + print(f"DEBUG: cvsps Command STDOUT:\n{result.stdout}", file=sys.stderr) + if result.stderr: + print(f"DEBUG: cvsps Command STDERR:\n{result.stderr}", file=sys.stderr) + + return result.stdout.strip() + except subprocess.CalledProcessError as e: + # Debug printout for command failure + print(f"DEBUG: cvsps Command FAILED", file=sys.stderr) + print(f"DEBUG: Return Code: {e.returncode}", file=sys.stderr) + print(f"DEBUG: Command: {' '.join(e.cmd)}", file=sys.stderr) + print(f"DEBUG: STDOUT:\n{e.stdout}", file=sys.stderr) + print(f"DEBUG: STDERR:\n{e.stderr}", file=sys.stderr) + + # Re-raise to allow caller to handle specific errors + raise + def _checkout_repository(self): """ Checkout or update the local repository @@ -179,12 +225,13 @@ class CVSClient: :rtype: list """ try: - # Use 'cvs log' instead of 'rlog' as it's more widely supported + # Use 'cvs log' to get revision history for the file output = self._run_cvs_command(['log', file_path]) # Parse log output to extract revision details revisions = [] current_revision = {} + in_log = False for line in output.split('\n'): # Look for revision lines (format: "revision X.X") @@ -198,6 +245,7 @@ class CVSClient: if current_revision and 'revision' in current_revision: revisions.append(current_revision) current_revision = {'revision': rev_match.group(1)} + in_log = False if date_match: current_revision.update({ @@ -206,11 +254,35 @@ class CVSClient: 'state': date_match.group(3), 'lines_changed': 'N/A' # cvs log doesn't provide line counts }) + in_log = False + + # Capture log message (lines after date/author/state until next revision or separator) + if in_log: + if line.strip() == '' or re.match(r'^---', line): + in_log = False + elif not re.match(r'^(revision|date|branches):', line): + if 'log' not in current_revision: + current_revision['log'] = '' + if current_revision['log']: + current_revision['log'] += '\n' + line + else: + current_revision['log'] = line + + # Start capturing log after date line + if date_match: + in_log = True # Add the last revision if current_revision and 'revision' in current_revision: revisions.append(current_revision) + # Clean up logs - strip whitespace and take first line only + for rev in revisions: + if 'log' in rev: + rev['log'] = rev['log'].strip().split('\n')[0] + else: + rev['log'] = '' + return revisions except subprocess.CalledProcessError as e: print(f"DEBUG: Error getting file history: {e}", file=sys.stderr) @@ -235,4 +307,125 @@ class CVSClient: return self._run_cvs_command(command) except subprocess.CalledProcessError: - return f"Error retrieving content for {file_path}" \ No newline at end of file + return f"Error retrieving content for {file_path}" + + def get_patchsets(self): + """ + Get repository patchset history using cvsps command + + Note: cvsps must be run from the module directory, not the repository root + + :return: List of patchset details + :rtype: list + """ + try: + # Determine the working directory for cvsps + # If a module is specified, use the module directory; otherwise use the repository root + if self.cvs_module: + module_path = os.path.join(self.local_repo_path, self.cvs_module) + cvsps_cwd = module_path if os.path.exists(module_path) else self.local_repo_path + else: + cvsps_cwd = self.local_repo_path + + # Use 'cvsps' to get all patch sets in the repository + # cvsps must be run from the module directory + output = self._run_cvsps_command([], cwd=cvsps_cwd) + + # Parse cvsps output to extract patchset details + patchsets = [] + current_patchset = {} + in_log = False + + for line in output.split('\n'): + # Look for PatchSet lines (format: "PatchSet XXXX") + ps_match = re.match(r'^PatchSet\s+(\d+)', line) + + # Look for Date line (format: "Date: YYYY/MM/DD HH:MM:SS") + date_match = re.match(r'^Date:\s+(\d{4}/\d{2}/\d{2}\s+\d{2}:\d{2}:\d{2})', line) + + # Look for Author line (format: "Author: NAME") + author_match = re.match(r'^Author:\s+(\S+)', line) + + # Look for Tag line (format: "Tag: TAG_NAME") + tag_match = re.match(r'^Tag:\s+(\S+)', line) + + # Look for Log line (format: "Log:") + log_match = re.match(r'^Log:', line) + + if ps_match: + # Start of a new patch set + if current_patchset and 'patchset' in current_patchset: + patchsets.append(current_patchset) + current_patchset = {'patchset': ps_match.group(1)} + in_log = False + + if date_match: + current_patchset['date'] = date_match.group(1) + + if author_match: + current_patchset['author'] = author_match.group(1) + + if tag_match: + current_patchset['tag'] = tag_match.group(1) + + if log_match: + # Log section starts, capture until next PatchSet or end + current_patchset['log'] = '' + in_log = True + elif in_log: + # Capture log lines until we hit an empty line or next section + if line.strip() == '': + in_log = False + elif not re.match(r'^(PatchSet|Date|Author|Tag|Files):', line): + # Append to log if it's not a new section header + if current_patchset['log']: + current_patchset['log'] += '\n' + line + else: + current_patchset['log'] = line + + # Add the last patchset + if current_patchset and 'patchset' in current_patchset: + patchsets.append(current_patchset) + + # Ensure all patchsets have the required fields with defaults + for ps in patchsets: + if 'date' not in ps: + ps['date'] = 'N/A' + if 'author' not in ps: + ps['author'] = 'N/A' + if 'tag' not in ps: + ps['tag'] = 'N/A' + if 'log' not in ps: + ps['log'] = '' + # Clean up log - remove leading/trailing whitespace and limit to first line for compact display + ps['log'] = ps['log'].strip() + + return patchsets + except subprocess.CalledProcessError as e: + print(f"DEBUG: Error getting patchsets: {e}", file=sys.stderr) + return [] + + def get_patchset_diff(self, patchset_number): + """ + Get diff for a specific patchset using cvsps command + + :param patchset_number: Patchset number + :type patchset_number: str + :return: Diff output for the patchset + :rtype: str + """ + try: + # Determine the working directory for cvsps + # If a module is specified, use the module directory; otherwise use the repository root + if self.cvs_module: + module_path = os.path.join(self.local_repo_path, self.cvs_module) + cvsps_cwd = module_path if os.path.exists(module_path) else self.local_repo_path + else: + cvsps_cwd = self.local_repo_path + + # Use 'cvsps' with -s flag to select the patchset and -g flag to generate the diff + output = self._run_cvsps_command(['-s', patchset_number, '-g'], cwd=cvsps_cwd) + return output + except subprocess.CalledProcessError as e: + print(f"DEBUG: Error getting patchset diff: {e}", file=sys.stderr) + return f"Error retrieving diff for patchset {patchset_number}" \ No newline at end of file diff --git a/cvs_proxy/test_cvs_client.py b/cvs_proxy/test_cvs_client.py index 8ea7c72..b00a10a 100644 --- a/cvs_proxy/test_cvs_client.py +++ b/cvs_proxy/test_cvs_client.py @@ -143,7 +143,7 @@ class TestCVSClient(unittest.TestCase): @patch('cvs_proxy.cvs_client.subprocess.run') def test_get_file_history(self, mock_run): - """Test retrieving file history""" + """Test retrieving file history using cvs log""" mock_run.return_value = MagicMock( stdout=''' revision 1.2 @@ -160,6 +160,16 @@ date: 2023/11/19 15:30:00; author: testuser; state: Exp; lines: +10 -0 self.assertEqual(len(history), 2) self.assertIn('revision', history[0]) self.assertEqual(history[0]['revision'], '1.2') + self.assertEqual(history[0]['author'], 'testuser') + self.assertEqual(history[0]['date'], '2023/11/20 10:00:00') + self.assertEqual(history[0]['state'], 'Exp') + + # Verify the cvs log command was called correctly + mock_run.assert_called() + call_args = mock_run.call_args[0][0] + self.assertEqual(call_args[0], 'cvs') + self.assertIn('log', call_args) + self.assertIn('file.txt', call_args) @patch('cvs_proxy.cvs_client.subprocess.run') def test_get_file_content(self, mock_run): diff --git a/ui/api.js b/ui/api.js index d2b8f2d..5346bf4 100644 --- a/ui/api.js +++ b/ui/api.js @@ -99,6 +99,25 @@ class CVSProxyAPI { return this.request(`/diff?${params.toString()}`); } + /** + * Get repository patchset history + * @returns {Promise} Array of patchset objects + */ + async getPatchsets() { + return this.request('/patchsets'); + } + + /** + * Get diff for a specific patchset + * @param {string} patchset - Patchset number + * @returns {Promise} Diff object with diff property + */ + async getPatchsetDiff(patchset) { + const params = new URLSearchParams(); + params.append('patchset', patchset); + return this.request(`/patchset-diff?${params.toString()}`); + } + /** * Check API health * @returns {Promise} Health status diff --git a/ui/app.js b/ui/app.js index 0e4334c..b2de050 100644 --- a/ui/app.js +++ b/ui/app.js @@ -158,6 +158,38 @@ class CVSRepositoryBrowser { } } + /** + * Show patchsets view + */ + async showPatchsets() { + try { + ui.patchsetContent.innerHTML = '
Loading patchsets...
'; + const patchsets = await api.getPatchsets(); + ui.displayPatchsets(patchsets); + } catch (error) { + console.error('Error showing patchsets:', error); + ui.patchsetContent.innerHTML = `
Error loading patchsets: ${error.message}
`; + ui.showView(ui.patchsetView); + } + } + + /** + * Show patchset diff + * @param {string} patchset - Patchset number + */ + async showPatchsetDiff(patchset) { + try { + ui.patchsetDiffContent.innerHTML = '
Loading patchset diff...
'; + const diffResult = await api.getPatchsetDiff(patchset); + const diffText = diffResult.diff || diffResult; + ui.displayPatchsetDiff(diffText); + } catch (error) { + console.error('Error showing patchset diff:', error); + ui.patchsetDiffContent.innerHTML = `
Error loading patchset diff: ${error.message}
`; + ui.showView(ui.patchsetDiffView); + } + } + /** * Setup event listeners */ @@ -176,6 +208,17 @@ class CVSRepositoryBrowser { ui.backFromDiffBtn.addEventListener('click', () => { ui.showView(ui.fileView); }); + + // Patchset view buttons + ui.patchsetsBtn.addEventListener('click', () => this.showPatchsets()); + ui.backFromPatchsetBtn.addEventListener('click', () => { + ui.showView(ui.welcomeView); + }); + + // Patchset diff view back button + ui.backFromPatchsetDiffBtn.addEventListener('click', () => { + ui.showView(ui.patchsetView); + }); } } diff --git a/ui/index.html b/ui/index.html index 5ee5472..bf4c01f 100644 --- a/ui/index.html +++ b/ui/index.html @@ -14,6 +14,9 @@

CVS Repository Browser

+ Connecting...
@@ -87,6 +90,28 @@ + + + + + +
@@ -99,6 +124,7 @@
  • 📄 View file contents
  • 📋 Check revision history
  • 🔀 Compare file versions
  • +
  • 📦 View repository patchsets
  • diff --git a/ui/styles.css b/ui/styles.css index 8d55dda..e7fb047 100644 --- a/ui/styles.css +++ b/ui/styles.css @@ -283,59 +283,257 @@ main { .history-item { background-color: var(--surface-color); border: 1px solid var(--border-color); - border-radius: 0.5rem; - padding: 1rem; - margin-bottom: 1rem; + border-radius: 0.375rem; + padding: 0.625rem 0.75rem; + margin-bottom: 0.5rem; cursor: pointer; transition: all 0.2s; + display: grid; + grid-template-columns: 80px 1fr 150px 120px; + gap: 0.75rem; + align-items: center; } .history-item:hover { - box-shadow: var(--shadow-lg); + box-shadow: var(--shadow); + border-color: var(--primary-color); + background-color: var(--bg-color); +} + +.history-item.active { + background-color: var(--primary-color); + color: white; border-color: var(--primary-color); } +.history-item.active .history-revision, +.history-item.active .history-date, +.history-item.active .history-author, +.history-item.active .history-state, +.history-item.active .history-log { + color: white; +} + .history-item-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 0.5rem; + display: contents; } .history-revision { font-weight: 600; color: var(--primary-color); font-family: 'Monaco', 'Courier New', monospace; + font-size: 0.875rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.history-item.active .history-revision { + color: white; } .history-date { color: var(--text-secondary); - font-size: 0.875rem; + font-size: 0.75rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: right; +} + +.history-item.active .history-date { + color: rgba(255, 255, 255, 0.8); } .history-author { color: var(--text-secondary); - font-size: 0.875rem; - margin-bottom: 0.5rem; + font-size: 0.75rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.history-item.active .history-author { + color: rgba(255, 255, 255, 0.9); } .history-state { display: inline-block; - padding: 0.25rem 0.75rem; + padding: 0.125rem 0.5rem; border-radius: 0.25rem; - font-size: 0.75rem; + font-size: 0.65rem; font-weight: 600; background-color: var(--bg-color); color: var(--text-secondary); + text-align: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-left: 0.5rem; +} + +.history-item.active .history-state { + background-color: rgba(255, 255, 255, 0.2); + color: white; } .history-lines { color: var(--text-secondary); + font-size: 0.75rem; +} + +.history-item.active .history-lines { + color: rgba(255, 255, 255, 0.9); +} + +.history-log-section { + display: flex; + align-items: center; + gap: 0.5rem; + overflow: hidden; +} + +.history-log { + color: var(--text-secondary); + font-size: 0.75rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-style: italic; + flex: 1; +} + +.history-item.active .history-log { + color: rgba(255, 255, 255, 0.9); +} + +/* Patchset View */ +.patchset-content { + flex: 1; + overflow-y: auto; + padding: 1.5rem; +} + +.patchset-item { + background-color: var(--surface-color); + border: 1px solid var(--border-color); + border-radius: 0.375rem; + padding: 0.625rem 0.75rem; + margin-bottom: 0.5rem; + cursor: pointer; + transition: all 0.2s; + display: grid; + grid-template-columns: 100px 1fr 150px 120px; + gap: 0.75rem; + align-items: center; +} + +.patchset-item:hover { + box-shadow: var(--shadow); + border-color: var(--primary-color); + background-color: var(--bg-color); +} + +.patchset-item.active { + background-color: var(--primary-color); + color: white; + border-color: var(--primary-color); +} + +.patchset-item.active .patchset-number, +.patchset-item.active .patchset-date, +.patchset-item.active .patchset-author, +.patchset-item.active .patchset-tag, +.patchset-item.active .patchset-log { + color: white; +} + +.patchset-item-header { + display: contents; +} + +.patchset-number { + font-weight: 600; + color: var(--primary-color); + font-family: 'Monaco', 'Courier New', monospace; font-size: 0.875rem; - margin-top: 0.5rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.patchset-item.active .patchset-number { + color: white; +} + +.patchset-date { + color: var(--text-secondary); + font-size: 0.75rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: right; +} + +.patchset-item.active .patchset-date { + color: rgba(255, 255, 255, 0.8); +} + +.patchset-author { + color: var(--text-secondary); + font-size: 0.75rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.patchset-item.active .patchset-author { + color: rgba(255, 255, 255, 0.9); +} + +.patchset-tag { + display: inline-block; + padding: 0.125rem 0.5rem; + border-radius: 0.25rem; + font-size: 0.65rem; + font-weight: 600; + background-color: var(--bg-color); + color: var(--text-secondary); + text-align: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-left: 0.5rem; +} + +.patchset-item.active .patchset-tag { + background-color: rgba(255, 255, 255, 0.2); + color: white; +} + +.patchset-log-section { + display: flex; + align-items: center; + gap: 0.5rem; + overflow: hidden; +} + +.patchset-log { + color: var(--text-secondary); + font-size: 0.75rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-style: italic; + flex: 1; +} + +.patchset-item.active .patchset-log { + color: rgba(255, 255, 255, 0.9); } /* Diff View */ + .view-header { padding: 1.5rem; border-bottom: 1px solid var(--border-color); diff --git a/ui/ui.js b/ui/ui.js index d55fe3f..69770f8 100644 --- a/ui/ui.js +++ b/ui/ui.js @@ -11,12 +11,41 @@ class UIManager { this.initializeTheme(); } + /** + * Convert a date string to relative format (e.g., "2 hours ago") + * @param {string} dateStr - Date string in format "YYYY/MM/DD HH:MM:SS" + * @returns {string} Relative date string + */ + getRelativeDate(dateStr) { + try { + // Parse the date string (format: "YYYY/MM/DD HH:MM:SS") + const parts = dateStr.match(/(\d{4})\/(\d{2})\/(\d{2})\s+(\d{2}):(\d{2}):(\d{2})/); + if (!parts) return dateStr; + + const date = new Date(parts[1], parts[2] - 1, parts[3], parts[4], parts[5], parts[6]); + const now = new Date(); + const seconds = Math.floor((now - date) / 1000); + + if (seconds < 60) return 'just now'; + if (seconds < 3600) return `${Math.floor(seconds / 60)} minute${Math.floor(seconds / 60) > 1 ? 's' : ''} ago`; + if (seconds < 86400) return `${Math.floor(seconds / 3600)} hour${Math.floor(seconds / 3600) > 1 ? 's' : ''} ago`; + if (seconds < 604800) return `${Math.floor(seconds / 86400)} day${Math.floor(seconds / 86400) > 1 ? 's' : ''} ago`; + if (seconds < 2592000) return `${Math.floor(seconds / 604800)} week${Math.floor(seconds / 604800) > 1 ? 's' : ''} ago`; + if (seconds < 31536000) return `${Math.floor(seconds / 2592000)} month${Math.floor(seconds / 2592000) > 1 ? 's' : ''} ago`; + return `${Math.floor(seconds / 31536000)} year${Math.floor(seconds / 31536000) > 1 ? 's' : ''} ago`; + } catch (e) { + return dateStr; + } + } + initializeElements() { // Views this.welcomeView = document.getElementById('welcomeView'); this.fileView = document.getElementById('fileView'); this.historyView = document.getElementById('historyView'); this.diffView = document.getElementById('diffView'); + this.patchsetView = document.getElementById('patchsetView'); + this.patchsetDiffView = document.getElementById('patchsetDiffView'); // File view elements this.fileName = document.getElementById('fileName'); @@ -36,6 +65,15 @@ class UIManager { this.diffContent = document.getElementById('diffContent'); this.backFromDiffBtn = document.getElementById('backFromDiffBtn'); + // Patchset view elements + this.patchsetContent = document.getElementById('patchsetContent'); + this.backFromPatchsetBtn = document.getElementById('backFromPatchsetBtn'); + this.patchsetsBtn = document.getElementById('patchsetsBtn'); + + // Patchset diff view elements + this.patchsetDiffContent = document.getElementById('patchsetDiffContent'); + this.backFromPatchsetDiffBtn = document.getElementById('backFromPatchsetDiffBtn'); + // Tree elements this.treeContainer = document.getElementById('treeContainer'); @@ -122,7 +160,7 @@ class UIManager { * @param {HTMLElement} view - View to show */ showView(view) { - [this.welcomeView, this.fileView, this.historyView, this.diffView].forEach(v => { + [this.welcomeView, this.fileView, this.historyView, this.diffView, this.patchsetView, this.patchsetDiffView].forEach(v => { if (v) v.classList.add('hidden'); }); if (view) view.classList.remove('hidden'); @@ -202,13 +240,13 @@ class UIManager { const historyHTML = history.map(revision => `
    -
    - ${revision.revision} - ${revision.date || 'N/A'} +
    ${revision.revision}
    +
    +
    ${revision.log ? this.escapeHtml(revision.log) : ''}
    + ${revision.state && revision.state !== 'Exp' ? `${revision.state}` : ''}
    -
    Author: ${revision.author || 'Unknown'}
    - ${revision.state || 'Exp'} -
    ${revision.lines_changed || 'N/A'}
    +
    ${revision.author || 'Unknown'}
    +
    ${this.getRelativeDate(revision.date) || 'N/A'}
    `).join(''); @@ -218,6 +256,10 @@ class UIManager { // Add click handlers to history items this.historyContent.querySelectorAll('.history-item').forEach(item => { item.addEventListener('click', () => { + // Remove active class from all items + this.historyContent.querySelectorAll('.history-item').forEach(i => i.classList.remove('active')); + // Add active class to clicked item + item.classList.add('active'); const revision = item.dataset.revision; window.app.loadFileAtRevision(this.currentFile, revision); }); @@ -450,6 +492,75 @@ class UIManager { item.classList.remove('active'); }); } + + /** + * Display patchsets + * @param {Array} patchsets - Array of patchset objects + */ + displayPatchsets(patchsets) { + if (!patchsets || patchsets.length === 0) { + this.patchsetContent.innerHTML = '
    No patchsets available
    '; + this.showView(this.patchsetView); + return; + } + + const patchsetHTML = patchsets.map(ps => ` +
    +
    PatchSet #${ps.patchset}
    +
    +
    ${ps.log ? this.escapeHtml(ps.log) : ''}
    + ${ps.tag && ps.tag !== 'N/A' ? `${ps.tag}` : ''} +
    +
    ${ps.author || 'Unknown'}
    +
    ${this.getRelativeDate(ps.date) || 'N/A'}
    +
    + `).join(''); + + this.patchsetContent.innerHTML = patchsetHTML; + this.showView(this.patchsetView); + + // Add click handlers to patchset items + this.patchsetContent.querySelectorAll('.patchset-item').forEach(item => { + item.addEventListener('click', () => { + // Remove active class from all items + this.patchsetContent.querySelectorAll('.patchset-item').forEach(i => i.classList.remove('active')); + // Add active class to clicked item + item.classList.add('active'); + const patchset = item.dataset.patchset; + window.app.showPatchsetDiff(patchset); + }); + }); + } + + /** + * Display patchset diff + * @param {string} diffText - Diff content + */ + displayPatchsetDiff(diffText) { + if (!diffText) { + this.patchsetDiffContent.innerHTML = '
    No diff available
    '; + this.showView(this.patchsetDiffView); + return; + } + + // Create code element with diff language class + const codeElement = document.createElement('code'); + codeElement.className = 'language-diff'; + codeElement.textContent = diffText; + + const preElement = document.createElement('pre'); + preElement.appendChild(codeElement); + + this.patchsetDiffContent.innerHTML = ''; + this.patchsetDiffContent.appendChild(preElement); + + // Apply syntax highlighting + if (window.hljs) { + hljs.highlightElement(codeElement); + } + + this.showView(this.patchsetDiffView); + } } // Create global UI manager instance From df78bab6f487a4e300864b19bf2c575a377cd3fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Guti=C3=A9rrez=20de=20Quevedo=20P=C3=A9?= =?UTF-8?q?rez?= Date: Fri, 21 Nov 2025 18:49:10 +0100 Subject: [PATCH 2/4] fix: filter out (none) tags from patchsets - Modified cvs_client.py to skip tags with value '(none)' using case-insensitive comparison - Updated ui.js to also check for '(none)' tags when displaying patchsets - Patchsets without tags no longer show any tag badge in the UI --- cvs_proxy/cvs_client.py | 5 ++++- ui/ui.js | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/cvs_proxy/cvs_client.py b/cvs_proxy/cvs_client.py index 46dc874..aee6488 100644 --- a/cvs_proxy/cvs_client.py +++ b/cvs_proxy/cvs_client.py @@ -366,7 +366,10 @@ class CVSClient: current_patchset['author'] = author_match.group(1) if tag_match: - current_patchset['tag'] = tag_match.group(1) + tag_value = tag_match.group(1) + # Don't store tags that are "(none)" or "(None)" - treat them as no tag (case-insensitive) + if tag_value.lower() != '(none)': + current_patchset['tag'] = tag_value if log_match: # Log section starts, capture until next PatchSet or end diff --git a/ui/ui.js b/ui/ui.js index 69770f8..5d0c5d5 100644 --- a/ui/ui.js +++ b/ui/ui.js @@ -509,7 +509,7 @@ class UIManager {
    PatchSet #${ps.patchset}
    ${ps.log ? this.escapeHtml(ps.log) : ''}
    - ${ps.tag && ps.tag !== 'N/A' ? `${ps.tag}` : ''} + ${ps.tag && ps.tag !== 'N/A' && ps.tag.toLowerCase() !== '(none)' ? `${ps.tag}` : ''}
    ${ps.author || 'Unknown'}
    ${this.getRelativeDate(ps.date) || 'N/A'}
    From 761413151195f26c2304f4b326d2e82df5886c19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Guti=C3=A9rrez=20de=20Quevedo=20P=C3=A9?= =?UTF-8?q?rez?= Date: Fri, 21 Nov 2025 18:49:15 +0100 Subject: [PATCH 3/4] build: optimize Dockerfile for better layer caching and smaller image size - Combined apt-get commands into a single RUN layer - Added --no-install-recommends flag to avoid unnecessary packages - Cleaned up apt cache and removed package lists after installation - Reordered COPY commands to copy requirements.txt first for better Docker layer caching - Separated COPY of requirements.txt from the rest of the application code --- Dockerfile | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index cdec76b..7fadb96 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,13 +3,25 @@ FROM python:3-bookworm # Set working directory WORKDIR /app -# Install runtime dependencies (minimal) -RUN apt update && apt install cvs rsh-client cvsps +# Install runtime dependencies in a single layer and clean up apt cache +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + cvs \ + rsh-client \ + cvsps && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* -COPY . . +# Copy requirements first for better layer caching +COPY requirements.txt . # Install Python dependencies RUN pip install --no-cache-dir -r requirements.txt + +# Copy the rest of the application +COPY . . + +# Install the package RUN pip install --no-cache-dir -e . # Set environment variables for configuration (with defaults) @@ -26,4 +38,4 @@ ENV CVS_URL="" \ EXPOSE 5000 # Set the entrypoint to run the application with environment variables as command-line arguments -ENTRYPOINT ["sh", "-c", "python -m cvs_proxy.app --cvs-url \"$CVS_URL\" --repo-checkouts \"$REPO_CHECKOUTS\" ${CVS_MODULE:+--cvs-module \"$CVS_MODULE\"} ${BASEPATH:+--basepath \"$BASEPATH\"} --host \"$FLASK_HOST\" --port \"$FLASK_PORT\" ${FLASK_DEBUG:+--debug}"] +ENTRYPOINT ["sh", "-c", "python -m cvs_proxy.app --cvs-url \"$CVS_URL\" --repo-checkouts \"$REPO_CHECKOUTS\" ${CVS_MODULE:+--cvs-module \"$CVS_MODULE\"} ${BASEPATH:+--basepath \"$BASEPATH\"} --host \"$FLASK_HOST\" --port \"$FLASK_PORT\" ${FLASK_DEBUG:+--debug}"] \ No newline at end of file From 13f37be9c270be40faa458ef61a51fe37b0877a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Guti=C3=A9rrez=20de=20Quevedo=20P=C3=A9?= =?UTF-8?q?rez?= Date: Fri, 21 Nov 2025 19:18:46 +0100 Subject: [PATCH 4/4] Add Drone CI configuration --- .drone.yml | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .drone.yml diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..a49cbcc --- /dev/null +++ b/.drone.yml @@ -0,0 +1,35 @@ +kind: pipeline +type: docker +name: default + +steps: + # Test stage + - name: test + image: python:3-bookworm + commands: + - pip install --no-cache-dir -r requirements.txt + - pip install --no-cache-dir -e . + - python -m unittest discover cvs_proxy + + # Docker build and push stage + - name: docker-build-push + image: plugins/docker + settings: + registry: docker.gutierrezdequevedo.com + repo: docker.gutierrezdequevedo.com/ps/cvs-proxy + tags: + - latest + - ${DRONE_COMMIT_SHA:0:7} + dockerfile: Dockerfile + context: . + when: + status: + - success + depends_on: + - test + +trigger: + branch: + - "*" + event: + - push \ No newline at end of file