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
This commit is contained in:
Juan José Gutiérrez de Quevedo Pérez 2025-11-21 17:18:55 +01:00
parent c263092c10
commit d3b40ae93f
9 changed files with 660 additions and 26 deletions

View file

@ -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():
"""

View file

@ -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}"
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}"

View file

@ -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):