Skip to content

Commit a0f1c98

Browse files
warren830claude
andauthored
feat(azuredevops): add environment_pattern for extracting environment names from job/stage names (#8671)
This enhancement addresses the issue where the Azure DevOps plugin was unable to correctly detect production deployments when the environment name is embedded in job/stage names rather than being in a standard format. Changes: - Add `environment_pattern` scope config field that supports regex capture groups to extract environment names from job/stage names - Collect both Job and Stage records from the timeline API (previously only Job) - Add `identifier`, `type`, and `parent_id` fields to the Job model - Update environment detection logic: - If environment_pattern is configured, extract the environment name first - Apply production_pattern to the extracted environment name - Fall back to matching production_pattern against job name if no extraction - Fix default environment behavior: only default to PRODUCTION when production_pattern is not configured (was defaulting PRODUCTION always) - Add comprehensive tests for the new functionality Example configuration for pipelines with jobs like 'deploy_xxxx-prod_helm': - deployment_pattern: deploy - production_pattern: prod - environment_pattern: (?:deploy|predeploy)[_-](.+?)(?:[_-](?:helm|terraform))?$ This extracts 'xxxx-prod' from the job name and then applies production_pattern to correctly identify it as a production deployment. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.5 <[email protected]>
1 parent 07bc583 commit a0f1c98

File tree

4 files changed

+250
-10
lines changed

4 files changed

+250
-10
lines changed

backend/python/plugins/azuredevops/azuredevops/models.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ class GitRepositoryConfig(ScopeConfig):
3737
refdiff: Optional[RefDiffOptions]
3838
deployment_pattern: Optional[re.Pattern]
3939
production_pattern: Optional[re.Pattern]
40+
# Optional pattern with capture group to extract environment name from job/stage names
41+
# Example: r'(?:deploy|predeploy)[_-](.+?)(?:[_-](?:helm|terraform))?$' extracts 'xxxx-prod' from 'deploy_xxxx-prod_helm'
42+
environment_pattern: Optional[re.Pattern]
4043

4144

4245
class GitRepository(ToolScope, table=True):
@@ -146,3 +149,6 @@ def __str__(self) -> str:
146149
finish_time: Optional[datetime.datetime]
147150
state: JobState
148151
result: Optional[JobResult]
152+
identifier: Optional[str]
153+
type: Optional[str]
154+
parent_id: Optional[str] = Field(source='/parentId')

backend/python/plugins/azuredevops/azuredevops/streams/builds.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,16 @@ def convert(self, b: Build, ctx: Context):
7272
if ctx.scope_config.deployment_pattern and ctx.scope_config.deployment_pattern.search(b.name):
7373
type = devops.CICDType.DEPLOYMENT
7474

75-
environment = devops.CICDEnvironment.PRODUCTION
76-
if ctx.scope_config.production_pattern is not None and ctx.scope_config.production_pattern.search(
77-
b.name) is None:
78-
environment = None
75+
# Determine if this is a production environment
76+
# Match production_pattern against pipeline name
77+
environment = None
78+
if ctx.scope_config.production_pattern is not None:
79+
if ctx.scope_config.production_pattern.search(b.name):
80+
environment = devops.CICDEnvironment.PRODUCTION
81+
else:
82+
# No production_pattern configured - default to PRODUCTION for deployments
83+
if type == devops.CICDType.DEPLOYMENT:
84+
environment = devops.CICDEnvironment.PRODUCTION
7985

8086
if b.finish_time:
8187
duration_sec = abs(b.finish_time.timestamp() - b.start_time.timestamp())

backend/python/plugins/azuredevops/azuredevops/streams/jobs.py

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
# limitations under the License.
1515

1616
from http import HTTPStatus
17-
from typing import Iterable
17+
from typing import Iterable, Optional
1818

1919
import pydevlake.domain_layer.devops as devops
2020
from azuredevops.api import AzureDevOpsAPI
@@ -24,6 +24,31 @@
2424
from pydevlake.api import APIException
2525

2626

27+
def extract_environment_name(name: str, identifier: Optional[str], context: Context) -> Optional[str]:
28+
"""
29+
Extract environment name from job/stage name or identifier using environment_pattern.
30+
31+
The environment_pattern should contain a capture group to extract the environment name.
32+
For example: r'(?:deploy|predeploy)[_-](.+?)(?:[_-](?:helm|terraform))?$'
33+
This would extract 'xxxx-prod' from 'deploy_xxxx-prod_helm'
34+
"""
35+
if not context.scope_config.environment_pattern:
36+
return None
37+
38+
# Try to match against the name first
39+
match = context.scope_config.environment_pattern.search(name)
40+
if match and match.groups():
41+
return match.group(1)
42+
43+
# If no match on name and identifier is available, try identifier
44+
if identifier:
45+
match = context.scope_config.environment_pattern.search(identifier)
46+
if match and match.groups():
47+
return match.group(1)
48+
49+
return None
50+
51+
2752
class Jobs(Substream):
2853
tool_model = Job
2954
domain_types = [DomainType.CICD]
@@ -48,7 +73,8 @@ def collect(self, state, context, parent: Build) -> Iterable[tuple[object, dict]
4873
if response.status == HTTPStatus.NO_CONTENT:
4974
return
5075
for raw_job in response.json["records"]:
51-
if raw_job["type"] == "Job":
76+
# Collect both Job and Stage records to support environment detection from stages
77+
if raw_job["type"] in ("Job", "Stage"):
5278
raw_job["build_id"] = parent.domain_id()
5379
raw_job["x_request_url"] = response.get_url_with_query_string()
5480
raw_job["x_request_input"] = {
@@ -87,10 +113,26 @@ def convert(self, j: Job, ctx: Context) -> Iterable[devops.CICDPipeline]:
87113
type = devops.CICDType.BUILD
88114
if ctx.scope_config.deployment_pattern and ctx.scope_config.deployment_pattern.search(j.name):
89115
type = devops.CICDType.DEPLOYMENT
90-
environment = devops.CICDEnvironment.PRODUCTION
91-
if ctx.scope_config.production_pattern is not None and ctx.scope_config.production_pattern.search(
92-
j.name) is None:
93-
environment = None
116+
117+
# Extract environment name using the new environment_pattern if configured
118+
extracted_env_name = extract_environment_name(j.name, j.identifier, ctx)
119+
120+
# Determine if this is a production environment
121+
# Priority: 1) Use extracted environment name with production_pattern
122+
# 2) Fall back to matching production_pattern against job name
123+
environment = None
124+
if ctx.scope_config.production_pattern is not None:
125+
# If we extracted an environment name, use it for production matching
126+
if extracted_env_name:
127+
if ctx.scope_config.production_pattern.search(extracted_env_name):
128+
environment = devops.CICDEnvironment.PRODUCTION
129+
# Fall back to matching against job name
130+
elif ctx.scope_config.production_pattern.search(j.name):
131+
environment = devops.CICDEnvironment.PRODUCTION
132+
else:
133+
# No production_pattern configured - default to PRODUCTION for deployments
134+
if type == devops.CICDType.DEPLOYMENT:
135+
environment = devops.CICDEnvironment.PRODUCTION
94136

95137
if j.finish_time:
96138
duration_sec = abs(j.finish_time.timestamp() - j.start_time.timestamp())

backend/python/plugins/azuredevops/tests/streams_test.py

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,3 +360,189 @@ def test_pull_request_commits_stream():
360360
)
361361

362362
assert_stream_convert(AzureDevOpsPlugin, 'gitpullrequestcommits', raw, expected)
363+
364+
365+
@pytest.fixture
366+
def context_with_environment_pattern():
367+
"""Context with environment_pattern configured to extract environment names from job names."""
368+
return (
369+
ContextBuilder(AzureDevOpsPlugin())
370+
.with_connection(token='token')
371+
.with_scope_config(
372+
deployment_pattern='deploy',
373+
production_pattern='prod',
374+
# Pattern to extract environment name from job names like 'deploy_xxxx-prod_helm'
375+
environment_pattern=r'(?:deploy|predeploy)[_-](.+?)(?:[_-](?:helm|terraform))?$'
376+
)
377+
.with_scope('johndoe/test-repo', url='https://github.com/johndoe/test-repo')
378+
.build()
379+
)
380+
381+
382+
def test_jobs_stream_with_environment_pattern(context_with_environment_pattern):
383+
"""Test that environment_pattern extracts environment name and uses it for production matching."""
384+
raw = {
385+
'previousAttempts': [],
386+
'id': 'cfa20e98-6997-523c-4233-f0a7302c929f',
387+
'parentId': '9ecf18fe-987d-5811-7c63-300aecae35da',
388+
'type': 'Job',
389+
'name': 'deploy_xxxx-prod_helm', # environment name 'xxxx-prod' should be extracted
390+
'build_id': 'azuredevops:Build:1:12',
391+
'start_time': '2023-02-25T06:22:36.8066667Z',
392+
'finish_time': '2023-02-25T06:22:43.2333333Z',
393+
'currentOperation': None,
394+
'percentComplete': None,
395+
'state': 'completed',
396+
'result': 'succeeded',
397+
'resultCode': None,
398+
'changeId': 18,
399+
'lastModified': '0001-01-01T00:00:00',
400+
'workerName': 'Hosted Agent',
401+
'queueId': 9,
402+
'order': 1,
403+
'details': None,
404+
'errorCount': 0,
405+
'warningCount': 0,
406+
'url': None,
407+
'log': {
408+
'id': 10,
409+
'type': 'Container',
410+
'url': 'https://dev.azure.com/johndoe/7a3fd40e-2aed-4fac-bac9-511bf1a70206/_apis/build/builds/12/logs/10'
411+
},
412+
'task': None,
413+
'attempt': 1,
414+
'identifier': 'deploy_xxxx-prod_helm.__default'
415+
}
416+
417+
expected = devops.CICDTask(
418+
id='cfa20e98-6997-523c-4233-f0a7302c929f',
419+
name='deploy_xxxx-prod_helm',
420+
pipeline_id='azuredevops:Build:1:12',
421+
status=devops.CICDStatus.DONE,
422+
original_status='Completed',
423+
original_result='Succeeded',
424+
created_date='2023-02-25T06:22:36.8066667Z',
425+
started_date='2023-02-25T06:22:36.8066667Z',
426+
finished_date='2023-02-25T06:22:43.2333333Z',
427+
result=devops.CICDResult.SUCCESS,
428+
type=devops.CICDType.DEPLOYMENT,
429+
duration_sec=6.426667213439941,
430+
environment=devops.CICDEnvironment.PRODUCTION, # Should match because 'xxxx-prod' contains 'prod'
431+
cicd_scope_id=context_with_environment_pattern.scope.domain_id()
432+
)
433+
assert_stream_convert(AzureDevOpsPlugin, 'jobs', raw, expected, context_with_environment_pattern)
434+
435+
436+
def test_jobs_stream_with_environment_pattern_non_prod(context_with_environment_pattern):
437+
"""Test that non-prod environments are correctly identified."""
438+
raw = {
439+
'previousAttempts': [],
440+
'id': 'cfa20e98-6997-523c-4233-f0a7302c929f',
441+
'parentId': '9ecf18fe-987d-5811-7c63-300aecae35da',
442+
'type': 'Job',
443+
'name': 'deploy_xxxx-dev_helm', # environment name 'xxxx-dev' should be extracted, not prod
444+
'build_id': 'azuredevops:Build:1:12',
445+
'start_time': '2023-02-25T06:22:36.8066667Z',
446+
'finish_time': '2023-02-25T06:22:43.2333333Z',
447+
'currentOperation': None,
448+
'percentComplete': None,
449+
'state': 'completed',
450+
'result': 'succeeded',
451+
'resultCode': None,
452+
'changeId': 18,
453+
'lastModified': '0001-01-01T00:00:00',
454+
'workerName': 'Hosted Agent',
455+
'queueId': 9,
456+
'order': 1,
457+
'details': None,
458+
'errorCount': 0,
459+
'warningCount': 0,
460+
'url': None,
461+
'log': {
462+
'id': 10,
463+
'type': 'Container',
464+
'url': 'https://dev.azure.com/johndoe/7a3fd40e-2aed-4fac-bac9-511bf1a70206/_apis/build/builds/12/logs/10'
465+
},
466+
'task': None,
467+
'attempt': 1,
468+
'identifier': 'deploy_xxxx-dev_helm.__default'
469+
}
470+
471+
expected = devops.CICDTask(
472+
id='cfa20e98-6997-523c-4233-f0a7302c929f',
473+
name='deploy_xxxx-dev_helm',
474+
pipeline_id='azuredevops:Build:1:12',
475+
status=devops.CICDStatus.DONE,
476+
original_status='Completed',
477+
original_result='Succeeded',
478+
created_date='2023-02-25T06:22:36.8066667Z',
479+
started_date='2023-02-25T06:22:36.8066667Z',
480+
finished_date='2023-02-25T06:22:43.2333333Z',
481+
result=devops.CICDResult.SUCCESS,
482+
type=devops.CICDType.DEPLOYMENT,
483+
duration_sec=6.426667213439941,
484+
environment=None, # Should be None because 'xxxx-dev' does not contain 'prod'
485+
cicd_scope_id=context_with_environment_pattern.scope.domain_id()
486+
)
487+
assert_stream_convert(AzureDevOpsPlugin, 'jobs', raw, expected, context_with_environment_pattern)
488+
489+
490+
def test_stage_record_collected():
491+
"""Test that Stage records are also collected (not just Job records)."""
492+
context = (
493+
ContextBuilder(AzureDevOpsPlugin())
494+
.with_connection(token='token')
495+
.with_scope_config(
496+
deployment_pattern='deploy',
497+
production_pattern='prod'
498+
)
499+
.with_scope('johndoe/test-repo', url='https://github.com/johndoe/test-repo')
500+
.build()
501+
)
502+
503+
raw = {
504+
'previousAttempts': [],
505+
'id': 'stage-id-123',
506+
'parentId': None,
507+
'type': 'Stage', # This is a Stage record
508+
'name': 'deploy_prod_stage',
509+
'build_id': 'azuredevops:Build:1:12',
510+
'start_time': '2023-02-25T06:22:36.8066667Z',
511+
'finish_time': '2023-02-25T06:22:43.2333333Z',
512+
'currentOperation': None,
513+
'percentComplete': None,
514+
'state': 'completed',
515+
'result': 'succeeded',
516+
'resultCode': None,
517+
'changeId': 18,
518+
'lastModified': '0001-01-01T00:00:00',
519+
'workerName': None,
520+
'queueId': None,
521+
'order': 1,
522+
'details': None,
523+
'errorCount': 0,
524+
'warningCount': 0,
525+
'url': None,
526+
'log': None,
527+
'task': None,
528+
'attempt': 1,
529+
'identifier': 'deploy_prod_stage'
530+
}
531+
532+
expected = devops.CICDTask(
533+
id='stage-id-123',
534+
name='deploy_prod_stage',
535+
pipeline_id='azuredevops:Build:1:12',
536+
status=devops.CICDStatus.DONE,
537+
original_status='Completed',
538+
original_result='Succeeded',
539+
created_date='2023-02-25T06:22:36.8066667Z',
540+
started_date='2023-02-25T06:22:36.8066667Z',
541+
finished_date='2023-02-25T06:22:43.2333333Z',
542+
result=devops.CICDResult.SUCCESS,
543+
type=devops.CICDType.DEPLOYMENT,
544+
duration_sec=6.426667213439941,
545+
environment=devops.CICDEnvironment.PRODUCTION,
546+
cicd_scope_id=context.scope.domain_id()
547+
)
548+
assert_stream_convert(AzureDevOpsPlugin, 'jobs', raw, expected, context)

0 commit comments

Comments
 (0)