Skip to content

Commit 34994c8

Browse files
updating git config and added pr workflow
1 parent 13fcf9c commit 34994c8

File tree

20 files changed

+1249
-8
lines changed

20 files changed

+1249
-8
lines changed

CLAUDE.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,39 @@ Frontend:
4444
- [x] `DraftValidator`: implemented at `backend/application/model_validator/draft_validator.py`
4545
- [x] `validate_draft` view: fully wired to DraftValidator
4646

47+
### Git Config Feature — COMPLETE
48+
Security: Admin-only write endpoints, server-side token validation,
49+
project-level access check, log level fix, double encryption guard
50+
GitHub Actions: Branch creation, PR creation, PR workflow on commit
51+
GitLab: Full parity with GitHub — all 10 methods, MR support,
52+
self-hosted detection
53+
54+
- [x] Phase 1A: Admin-only endpoints + server-side token validation + log level fix + double encryption guard
55+
- [x] Phase 1B: Project-level access check on all git config operations
56+
- [x] Phase 2A: Branch + PR methods in GitHubService
57+
- [x] Phase 2B: PR workflow service + model fields + commit wire-in
58+
- [x] Phase 2C: branches, enable-pr-workflow, get-version-pr endpoints
59+
- [x] Phase 2D: PR workflow UI in GitConfigTab + PR badge in VersionTimeline
60+
- [x] Phase 3: GitLab support (GitLabService + factory + frontend labels)
61+
- [x] Manual PR workflow: pr_mode enum (disabled/auto/manual)
62+
- [x] git_branch_name on ModelVersion for manual PR tracking
63+
- [x] git_pr_service split: push_version_to_branch + create_pr_for_version
64+
- [x] create-pr endpoint: POST version/{n}/create-pr
65+
- [x] GitConfigTab: Segmented mode selector (Off/Auto PR/Manual PR)
66+
- [x] VersionTimeline: Create PR/MR in action dropdown (manual mode only)
67+
- [x] 409 handled gracefully — existing PR shown with link
68+
4769
### Bug fixes (post-migration)
4870
- `execute_version`: fixed concurrency (Redis lock + `select_for_update`)
4971
- `_serialize_version`: added `is_current` to response dict
5072
- `get_draft_status`: fixed `committed_data` lookup to use project-level ModelVersion (`config_model=None`) instead of model-scoped versions
5173
- dual-write: added `skip_draft_write` flag to `_update_model` — execution pipeline no longer creates false draft records
5274
- `set_current_version`: added cache invalidation after DB update
5375
- `handleExecuteSuccess`: added `loadDraftStatus()` call to clear stale draft indicator after execute
76+
- `execute_version`: model data now persists on success (restore only on failure)
77+
- `_update_config`: replaced `pr_workflow_enabled` with `pr_mode` in field list, added `_READONLY_PROPS` skip set
78+
- `enable_pr_workflow` view: sends `pr_mode` instead of `pr_workflow_enabled`
79+
- `_serialize_config`: added `pr_mode` to API response (was missing — caused UI to reset to Off on tab switch)
80+
- VersionTimeline: `isManualMode` → `prWorkflowEnabled` — Create PR button now shows for any enabled PR mode
81+
- GitConfigTab: added `useEffect` to sync `prMode`/`prBaseBranch`/`prBranchPrefix` from gitConfig on async load
5482
---
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Generated by Django 4.2.10 on 2026-04-02 17:40
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("core", "0004_add_is_current_to_modelversion"),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name="gitrepoconfig",
15+
name="pr_base_branch",
16+
field=models.CharField(default="main", max_length=255),
17+
),
18+
migrations.AddField(
19+
model_name="gitrepoconfig",
20+
name="pr_branch_prefix",
21+
field=models.CharField(default="visitran/", max_length=100),
22+
),
23+
migrations.AddField(
24+
model_name="gitrepoconfig",
25+
name="pr_workflow_enabled",
26+
field=models.BooleanField(default=False),
27+
),
28+
migrations.AddField(
29+
model_name="modelversion",
30+
name="pr_number",
31+
field=models.IntegerField(blank=True, null=True),
32+
),
33+
migrations.AddField(
34+
model_name="modelversion",
35+
name="pr_url",
36+
field=models.CharField(blank=True, max_length=500, null=True),
37+
),
38+
]
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Generated by Django 4.2.10 on 2026-04-02 18:34
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
("core", "0005_pr_workflow_fields"),
10+
]
11+
12+
operations = [
13+
migrations.RemoveField(
14+
model_name="gitrepoconfig",
15+
name="pr_workflow_enabled",
16+
),
17+
migrations.AddField(
18+
model_name="gitrepoconfig",
19+
name="pr_mode",
20+
field=models.CharField(
21+
choices=[("disabled", "Disabled"), ("auto", "Auto"), ("manual", "Manual")],
22+
default="disabled",
23+
max_length=20,
24+
),
25+
),
26+
migrations.AddField(
27+
model_name="modelversion",
28+
name="git_branch_name",
29+
field=models.CharField(blank=True, max_length=255, null=True),
30+
),
31+
]

backend/backend/core/models/git_repo_config.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,23 @@ class GitRepoConfig(DefaultOrganizationMixin, BaseModel):
6969
)
7070
error_message = models.TextField(blank=True, default="")
7171

72+
# PR workflow settings
73+
PR_MODE_DISABLED = "disabled"
74+
PR_MODE_AUTO = "auto"
75+
PR_MODE_MANUAL = "manual"
76+
PR_MODE_CHOICES = [
77+
(PR_MODE_DISABLED, "Disabled"),
78+
(PR_MODE_AUTO, "Auto"),
79+
(PR_MODE_MANUAL, "Manual"),
80+
]
81+
pr_mode = models.CharField(max_length=20, choices=PR_MODE_CHOICES, default=PR_MODE_DISABLED)
82+
pr_base_branch = models.CharField(max_length=255, default="main")
83+
pr_branch_prefix = models.CharField(max_length=100, default="visitran/")
84+
85+
@property
86+
def pr_workflow_enabled(self):
87+
return self.pr_mode != self.PR_MODE_DISABLED
88+
7289
is_deleted = models.BooleanField(default=False)
7390
created_by = models.JSONField(default=dict)
7491
last_modified_by = models.JSONField(default=dict)

backend/backend/core/models/model_version.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,11 @@ class ModelVersion(DefaultOrganizationMixin, BaseModel):
7979
)
8080
git_commit_sha = models.CharField(max_length=40, blank=True, default="")
8181

82+
# PR tracking
83+
git_branch_name = models.CharField(max_length=255, null=True, blank=True)
84+
pr_number = models.IntegerField(null=True, blank=True)
85+
pr_url = models.CharField(max_length=500, null=True, blank=True)
86+
8287
# Denormalized search fields (extracted from model_data for indexed queries)
8388
extracted_model_name = models.CharField(
8489
max_length=200, blank=True, default="", db_index=True

backend/backend/core/routers/git_config/urls.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,19 @@
22

33
from backend.core.routers.git_config.views import (
44
delete_git_config,
5+
enable_pr_workflow,
56
get_available_repos,
67
get_git_config,
8+
list_branches,
79
save_git_config,
810
test_git_connection,
911
)
1012

1113
urlpatterns = [
1214
path("/test", test_git_connection, name="test-git-connection"),
1315
path("/available-repos", get_available_repos, name="get-available-repos"),
16+
path("/branches", list_branches, name="list-branches"),
17+
path("/enable-pr-workflow", enable_pr_workflow, name="enable-pr-workflow"),
1418
path("/save", save_git_config, name="save-git-config"),
1519
path("/delete", delete_git_config, name="delete-git-config"),
1620
path("", get_git_config, name="get-git-config"),

backend/backend/core/routers/git_config/views.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@
88
from rest_framework.response import Response
99

1010
from backend.core.services import git_repo_config_service
11+
from backend.core.services.git_service import get_git_service
1112
from backend.core.utils import handle_http_request
1213
from backend.utils.constants import HTTPMethods
14+
from rbac.factory import handle_permission
1315

1416
logger = logging.getLogger(__name__)
1517

@@ -26,6 +28,7 @@ def get_git_config(request: Request, project_id: str) -> Response:
2628

2729
@api_view([HTTPMethods.POST])
2830
@handle_http_request
31+
@handle_permission
2932
def save_git_config(request: Request, project_id: str) -> Response:
3033
"""Create or update git repository configuration for a project."""
3134
config = git_repo_config_service.save_config(project_id=project_id, config_data=request.data)
@@ -34,6 +37,7 @@ def save_git_config(request: Request, project_id: str) -> Response:
3437

3538
@api_view([HTTPMethods.DELETE])
3639
@handle_http_request
40+
@handle_permission
3741
def delete_git_config(request: Request, project_id: str) -> Response:
3842
"""Remove git repository configuration (disable versioning)."""
3943
git_repo_config_service.delete_config(project_id=project_id)
@@ -45,6 +49,7 @@ def delete_git_config(request: Request, project_id: str) -> Response:
4549

4650
@api_view([HTTPMethods.POST])
4751
@handle_http_request
52+
@handle_permission
4853
def test_git_connection(request: Request, project_id: str) -> Response:
4954
"""Test connection to a git repository."""
5055
result = git_repo_config_service.test_connection(project_id=project_id, config_data=request.data)
@@ -57,3 +62,63 @@ def get_available_repos(request: Request, project_id: str) -> Response:
5762
"""List repos already configured in the organization."""
5863
repos = git_repo_config_service.get_available_repos()
5964
return Response(data={"status": "success", "data": repos}, status=status.HTTP_200_OK)
65+
66+
67+
@api_view([HTTPMethods.GET])
68+
@handle_http_request
69+
def list_branches(request: Request, project_id: str) -> Response:
70+
"""List branches in the project's configured git repository."""
71+
from backend.core.models.git_repo_config import GitRepoConfig
72+
73+
config = GitRepoConfig.objects.filter(
74+
project_id=project_id, is_deleted=False, is_active=True,
75+
).first()
76+
if not config:
77+
return Response(
78+
data={"status": "failed", "error_message": "No git configuration found for this project."},
79+
status=status.HTTP_404_NOT_FOUND,
80+
)
81+
service = get_git_service(config)
82+
branches = service.list_branches()
83+
return Response(data={"status": "success", "data": {"branches": branches}}, status=status.HTTP_200_OK)
84+
85+
86+
@api_view([HTTPMethods.POST])
87+
@handle_http_request
88+
@handle_permission
89+
def enable_pr_workflow(request: Request, project_id: str) -> Response:
90+
"""Enable PR workflow for a project's git configuration."""
91+
payload = request.data
92+
pr_mode = payload.get("pr_mode", "auto")
93+
pr_base_branch = payload.get("pr_base_branch", "main")
94+
pr_branch_prefix = payload.get("pr_branch_prefix", "visitran/")
95+
96+
from backend.core.models.git_repo_config import GitRepoConfig
97+
98+
config = GitRepoConfig.objects.filter(
99+
project_id=project_id, is_deleted=False, is_active=True,
100+
).first()
101+
if not config:
102+
return Response(
103+
data={"status": "failed", "error_message": "No git configuration found for this project."},
104+
status=status.HTTP_404_NOT_FOUND,
105+
)
106+
107+
# Validate that the base branch exists
108+
service = get_git_service(config)
109+
branch_info = service.get_branch(pr_base_branch)
110+
if not branch_info:
111+
return Response(
112+
data={"status": "failed", "error_message": f"Branch '{pr_base_branch}' not found in repository."},
113+
status=status.HTTP_400_BAD_REQUEST,
114+
)
115+
116+
updated = git_repo_config_service.save_config(
117+
project_id=project_id,
118+
config_data={
119+
"pr_mode": pr_mode,
120+
"pr_base_branch": pr_base_branch,
121+
"pr_branch_prefix": pr_branch_prefix,
122+
},
123+
)
124+
return Response(data={"status": "success", "data": updated}, status=status.HTTP_200_OK)

backend/backend/core/routers/version_history/urls.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@
1111
get_audit_events,
1212
get_conflicts,
1313
get_draft,
14+
create_version_pr,
1415
get_current_version,
1516
get_draft_status,
17+
get_version_pr,
1618
get_version_by_id,
1719
get_version_detail,
1820
get_version_history,
@@ -45,6 +47,8 @@
4547
path("/audit", get_audit_events, name="get-audit-events"),
4648
path("/version/<int:version_number>", get_version_detail, name="get-version-detail"),
4749
path("/version/<int:version_number>/verify", verify_version_integrity, name="verify-version-integrity"),
50+
path("/version/<int:version_number>/pr", get_version_pr, name="get-version-pr"),
51+
path("/version/<int:version_number>/create-pr", create_version_pr, name="create-version-pr"),
4852
path("/version-id/<str:version_id>", get_version_by_id, name="get-version-by-id"),
4953
# Model-scoped (draft/conflict) endpoints
5054
path("/<str:model_name>/draft", get_draft, name="get-draft"),

backend/backend/core/routers/version_history/views.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,59 @@ def get_current_version(request: Request, project_id: str) -> Response:
115115
return Response(data={"status": "success", "data": data}, status=status.HTTP_200_OK)
116116

117117

118+
@api_view([HTTPMethods.GET])
119+
@handle_http_request
120+
def get_version_pr(request: Request, project_id: str, version_number: int) -> Response:
121+
"""Get PR info associated with a version."""
122+
project_instance = _get_project(project_id)
123+
version = model_version_service.get_version(project_instance, version_number)
124+
if not version.pr_number:
125+
return Response(
126+
data={"status": "failed", "error_message": "No PR associated with this version."},
127+
status=status.HTTP_404_NOT_FOUND,
128+
)
129+
return Response(data={"status": "success", "data": {
130+
"pr_number": version.pr_number,
131+
"pr_url": version.pr_url or "",
132+
"version_number": version.version_number,
133+
}}, status=status.HTTP_200_OK)
134+
135+
136+
@api_view([HTTPMethods.POST])
137+
@handle_http_request
138+
def create_version_pr(request: Request, project_id: str, version_number: int) -> Response:
139+
"""Create a PR for a version that has been pushed to a branch (manual mode)."""
140+
from backend.core.models.git_repo_config import GitRepoConfig
141+
from backend.core.services import git_pr_service
142+
143+
project_instance = _get_project(project_id)
144+
try:
145+
version = ModelVersion.objects.get(project_instance=project_instance, version_number=version_number)
146+
except ModelVersion.DoesNotExist:
147+
return Response(data={"status": "failed", "error_message": f"Version {version_number} not found."}, status=status.HTTP_404_NOT_FOUND)
148+
149+
if version.pr_number:
150+
return Response(data={"status": "failed", "data": {
151+
"pr_number": version.pr_number, "pr_url": version.pr_url or "",
152+
"message": "PR already exists for this version",
153+
}}, status=status.HTTP_409_CONFLICT)
154+
155+
config = GitRepoConfig.objects.filter(project_id=project_id, is_deleted=False, is_active=True).first()
156+
if not config:
157+
return Response(data={"status": "failed", "error_message": "Git is not configured for this project."}, status=status.HTTP_400_BAD_REQUEST)
158+
159+
if config.pr_mode == "disabled":
160+
return Response(data={"status": "failed", "error_message": "PR workflow is not enabled for this project."}, status=status.HTTP_400_BAD_REQUEST)
161+
162+
pr_result = git_pr_service.create_pr_for_version(project_instance, version, config)
163+
return Response(data={"status": "success", "data": {
164+
"pr_number": pr_result["pr_number"],
165+
"pr_url": pr_result["pr_url"],
166+
"version_number": version.version_number,
167+
"message": "PR created successfully",
168+
}}, status=status.HTTP_200_OK)
169+
170+
118171
@api_view([HTTPMethods.GET])
119172
@handle_http_request
120173
def compare_versions(request: Request, project_id: str) -> Response:

0 commit comments

Comments
 (0)