Skip to content

Commit 73f99f4

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 73f99f4

2 files changed

Lines changed: 177 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: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
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+
p1 = MagicMock(patch_url="https://github.com/u/r/commit/c1", patch_text=None)
49+
p2 = MagicMock(patch_url="https://github.com/u/r/pull/1", patch_text="")
50+
p3 = MagicMock(patch_url="https://example.com/no-patch", patch_text=None)
51+
p4 = MagicMock(patch_url="https://example.com/fix.patch", patch_text=None)
52+
53+
mock_qs = MagicMock()
54+
mock_qs.count.return_value = 4
55+
mock_qs.iterator.return_value = [p1, p2, p3, p4]
56+
57+
mock_patch_model.objects.filter.return_value = mock_qs
58+
59+
def side_effect(url, timeout=10):
60+
mock_resp = MagicMock()
61+
mock_resp.status_code = 404
62+
if url == "https://github.com/u/r/commit/c1.patch":
63+
mock_resp.status_code = 200
64+
mock_resp.text = "diff --git a/file b/file\n+code"
65+
elif url == "https://github.com/u/r/pull/1.patch":
66+
mock_resp.status_code = 200
67+
mock_resp.text = "diff --git a/pr b/pr\n+pr_code"
68+
elif url == "https://example.com/fix.patch":
69+
mock_resp.status_code = 200
70+
mock_resp.text = "diff --git a/direct b/direct\n+direct_code"
71+
return mock_resp
72+
73+
mock_get.side_effect = side_effect
74+
75+
self.pipeline.collect_and_store_patch_texts()
76+
77+
self.assertEqual(p1.patch_text, "diff --git a/file b/file\n+code")
78+
p1.save.assert_called_once()
79+
80+
self.assertEqual(p2.patch_text, "diff --git a/pr b/pr\n+pr_code")
81+
p2.save.assert_called_once()
82+
83+
p3.save.assert_not_called()
84+
85+
self.assertEqual(p4.patch_text, "diff --git a/direct b/direct\n+direct_code")
86+
p4.save.assert_called_once()

0 commit comments

Comments
 (0)