Skip to content

Commit fe97346

Browse files
feat: Add a pipeline to automatically collect and store patch texts from URLs for Patch objects.
Signed-off-by: Dhirenderchoudhary <dhirenderchoudhary0001@gmail.com>
1 parent 053c8fb commit fe97346

2 files changed

Lines changed: 188 additions & 0 deletions

File tree

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
#
2+
# Copyright (c) nexB Inc. and others. All rights reserved.
3+
# VulnerableCode is a trademark of nexB Inc.
4+
# SPDX-License-Identifier: Apache-2.0
5+
# See http://www.apache.org/licenses/LICENSE-2.0 for the license text.
6+
# See https://github.com/aboutcode-org/vulnerablecode for support or download.
7+
# See https://aboutcode.org for more information about nexB OSS projects.
8+
#
9+
10+
import logging
11+
12+
import requests
13+
from aboutcode.pipeline import LoopProgress
14+
from django.db.models import Q
15+
16+
from vulnerabilities.models import Patch
17+
from vulnerabilities.pipelines import VulnerableCodePipeline
18+
19+
20+
class CollectPatchTextsPipeline(VulnerableCodePipeline):
21+
"""
22+
Improver pipeline to collect missing patch texts for Patch objects that have a patch_url.
23+
"""
24+
25+
pipeline_id = "collect_patch_texts_v2"
26+
license_expression = None
27+
28+
@classmethod
29+
def steps(cls):
30+
return (cls.collect_and_store_patch_texts,)
31+
32+
def collect_and_store_patch_texts(self):
33+
patches_without_text = Patch.objects.filter(
34+
Q(patch_url__isnull=False) & ~Q(patch_url=""),
35+
Q(patch_text__isnull=True) | Q(patch_text=""),
36+
)
37+
38+
self.log(f"Processing {patches_without_text.count():,d} patches to collect text.")
39+
40+
updated_patch_count = 0
41+
progress = LoopProgress(total_iterations=patches_without_text.count(), logger=self.log)
42+
43+
for patch in progress.iter(patches_without_text.iterator(chunk_size=500)):
44+
raw_url = get_raw_patch_url(patch.patch_url)
45+
if not raw_url:
46+
continue
47+
48+
try:
49+
response = requests.get(raw_url, timeout=10)
50+
if response.status_code == 200:
51+
patch.patch_text = response.text
52+
patch.save()
53+
updated_patch_count += 1
54+
else:
55+
self.log(
56+
f"Failed to fetch patch from {raw_url}: Status {response.status_code}",
57+
level=logging.WARNING if response.status_code < 500 else logging.ERROR,
58+
)
59+
except requests.RequestException as e:
60+
self.log(f"Error fetching patch from {raw_url}: {e}", level=logging.ERROR)
61+
62+
self.log(f"Successfully collected text for {updated_patch_count:,d} Patch entries.")
63+
64+
65+
def get_raw_patch_url(url):
66+
"""
67+
Return a fetchable raw patch URL from common VCS hosting URLs,
68+
or the URL itself if it already points to a .patch or .diff file.
69+
Return None if the URL type is not recognized.
70+
"""
71+
if not url:
72+
return None
73+
74+
url = url.strip()
75+
76+
if "github.com" in url and "/commit/" in url and not url.endswith(".patch"):
77+
return f"{url}.patch"
78+
79+
if "github.com" in url and "/pull/" in url and not url.endswith(".patch"):
80+
return f"{url}.patch"
81+
82+
if "gitlab.com" in url and "/commit/" in url and not url.endswith(".patch"):
83+
return f"{url}.patch"
84+
85+
if "gitlab.com" in url and "/merge_requests/" in url and not url.endswith(".patch"):
86+
return f"{url}.patch"
87+
88+
if url.endswith(".patch") or url.endswith(".diff"):
89+
return url
90+
91+
return None
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
#
2+
# Copyright (c) nexB Inc. and others. All rights reserved.
3+
# VulnerableCode is a trademark of nexB Inc.
4+
# SPDX-License-Identifier: Apache-2.0
5+
# See http://www.apache.org/licenses/LICENSE-2.0 for the license text.
6+
# See https://github.com/aboutcode-org/vulnerablecode for support or download.
7+
# See https://aboutcode.org for more information about nexB OSS projects.
8+
#
9+
10+
import unittest
11+
from unittest.mock import MagicMock
12+
from unittest.mock import patch as mock_patch
13+
14+
from vulnerabilities.pipelines.v2_improvers.collect_patch_texts import CollectPatchTextsPipeline
15+
from vulnerabilities.pipelines.v2_improvers.collect_patch_texts import get_raw_patch_url
16+
17+
18+
class TestCollectPatchTextsPipeline(unittest.TestCase):
19+
def setUp(self):
20+
self.pipeline = CollectPatchTextsPipeline()
21+
22+
def test_get_raw_patch_url(self):
23+
url = "https://github.com/user/repo/commit/abc1234567890"
24+
expected = "https://github.com/user/repo/commit/abc1234567890.patch"
25+
self.assertEqual(get_raw_patch_url(url), expected)
26+
27+
url = "https://github.com/user/repo/pull/123"
28+
expected = "https://github.com/user/repo/pull/123.patch"
29+
self.assertEqual(get_raw_patch_url(url), expected)
30+
31+
url = "https://gitlab.com/user/repo/-/commit/abc1234567890"
32+
expected = "https://gitlab.com/user/repo/-/commit/abc1234567890.patch"
33+
self.assertEqual(get_raw_patch_url(url), expected)
34+
35+
url = "https://gitlab.com/user/repo/-/merge_requests/123"
36+
expected = "https://gitlab.com/user/repo/-/merge_requests/123.patch"
37+
self.assertEqual(get_raw_patch_url(url), expected)
38+
39+
url = "https://example.com/fix.patch"
40+
self.assertEqual(get_raw_patch_url(url), url)
41+
42+
url = "https://example.com/some/article"
43+
self.assertIsNone(get_raw_patch_url(url))
44+
45+
@mock_patch("vulnerabilities.pipelines.v2_improvers.collect_patch_texts.Patch")
46+
@mock_patch("requests.get")
47+
def test_collect_and_store_patch_texts(self, mock_get, mock_patch_model):
48+
# Setup mock Patch objects
49+
p1 = MagicMock(patch_url="https://github.com/u/r/commit/c1", patch_text=None)
50+
p2 = MagicMock(patch_url="https://github.com/u/r/pull/1", patch_text="")
51+
p3 = MagicMock(patch_url="https://example.com/no-patch", patch_text=None)
52+
p4 = MagicMock(patch_url="https://example.com/fix.patch", patch_text=None)
53+
54+
# Mock the queryset iterator
55+
mock_qs = MagicMock()
56+
mock_qs.count.return_value = 4
57+
# iterator() relies on chunk_size, we just return the list
58+
mock_qs.iterator.return_value = [p1, p2, p3, p4]
59+
60+
# filter() returns the mock_qs
61+
mock_patch_model.objects.filter.return_value = mock_qs
62+
63+
def side_effect(url, timeout=10):
64+
mock_resp = MagicMock()
65+
mock_resp.status_code = 404
66+
if url == "https://github.com/u/r/commit/c1.patch":
67+
mock_resp.status_code = 200
68+
mock_resp.text = "diff --git a/file b/file\n+code"
69+
elif url == "https://github.com/u/r/pull/1.patch":
70+
mock_resp.status_code = 200
71+
mock_resp.text = "diff --git a/pr b/pr\n+pr_code"
72+
elif url == "https://example.com/fix.patch":
73+
mock_resp.status_code = 200
74+
mock_resp.text = "diff --git a/direct b/direct\n+direct_code"
75+
return mock_resp
76+
77+
mock_get.side_effect = side_effect
78+
79+
self.pipeline.collect_and_store_patch_texts()
80+
81+
# Assertions
82+
# p1 should be updated and saved
83+
self.assertEqual(p1.patch_text, "diff --git a/file b/file\n+code")
84+
p1.save.assert_called_once()
85+
86+
# p2 should be updated and saved
87+
self.assertEqual(p2.patch_text, "diff --git a/pr b/pr\n+pr_code")
88+
p2.save.assert_called_once()
89+
90+
# p3 should NOT be updated (didn't fetch)
91+
# It was None initially, should remain None or whatever logic left it.
92+
# But specifically save() should NOT be called if we didn't update it
93+
p3.save.assert_not_called()
94+
95+
# p4 should be updated and saved
96+
self.assertEqual(p4.patch_text, "diff --git a/direct b/direct\n+direct_code")
97+
p4.save.assert_called_once()

0 commit comments

Comments
 (0)