fetch_util 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +49 -0
  3. data/README.md +3 -1
  4. data/Rakefile +0 -2
  5. data/lib/fetch_util/assets/extract.js +1 -1
  6. data/lib/fetch_util/browser/interaction_helpers/consent_helpers.rb +12 -17
  7. data/lib/fetch_util/browser/interaction_helpers/timing_helpers.rb +42 -15
  8. data/lib/fetch_util/browser/site_stabilization/azure_devops_pr_commit_details_script.rb +48 -0
  9. data/lib/fetch_util/browser/site_stabilization/azure_devops_pr_product_state_script.rb +80 -0
  10. data/lib/fetch_util/browser/site_stabilization/azure_devops_pr_request_helpers_script.rb +53 -0
  11. data/lib/fetch_util/browser/site_stabilization/azure_devops_pr_request_state_script.rb +118 -0
  12. data/lib/fetch_util/browser/site_stabilization/azure_devops_pr_stabilization.rb +54 -0
  13. data/lib/fetch_util/browser/site_stabilization/azure_devops_pr_state.rb +27 -0
  14. data/lib/fetch_util/browser/site_stabilization/bitbucket_cloud_pull_diff_product_state.rb +55 -0
  15. data/lib/fetch_util/browser/site_stabilization/bitbucket_cloud_pull_diff_request_state.rb +100 -0
  16. data/lib/fetch_util/browser/site_stabilization/bitbucket_cloud_pull_diff_state.rb +59 -0
  17. data/lib/fetch_util/browser/site_stabilization/bitbucket_cloud_pull_resource_modules.rb +26 -0
  18. data/lib/fetch_util/browser/site_stabilization/bitbucket_cloud_pull_resource_state.rb +93 -0
  19. data/lib/fetch_util/browser/site_stabilization/bitbucket_cloud_pull_resources.rb +73 -0
  20. data/lib/fetch_util/browser/site_stabilization/bitbucket_cloud_thread_state.rb +92 -0
  21. data/lib/fetch_util/browser/site_stabilization/bitbucket_cloud_threads.rb +71 -0
  22. data/lib/fetch_util/browser/site_stabilization/community_and_marketplace.rb +9 -10
  23. data/lib/fetch_util/browser/site_stabilization/facebook_stabilization.rb +56 -0
  24. data/lib/fetch_util/browser/site_stabilization/gerrit_change_product_state_script.rb +97 -0
  25. data/lib/fetch_util/browser/site_stabilization/gerrit_change_request_state_script.rb +75 -0
  26. data/lib/fetch_util/browser/site_stabilization/gerrit_change_stabilization.rb +37 -0
  27. data/lib/fetch_util/browser/site_stabilization/gerrit_change_state.rb +39 -0
  28. data/lib/fetch_util/browser/site_stabilization/gerrit_file_resource_fetch_state_script.rb +31 -0
  29. data/lib/fetch_util/browser/site_stabilization/gerrit_file_resource_product_state_script.rb +76 -0
  30. data/lib/fetch_util/browser/site_stabilization/gerrit_file_resource_request_state_script.rb +111 -0
  31. data/lib/fetch_util/browser/site_stabilization/gerrit_file_resource_route_state_script.rb +71 -0
  32. data/lib/fetch_util/browser/site_stabilization/gerrit_file_resource_stabilization.rb +37 -0
  33. data/lib/fetch_util/browser/site_stabilization/gerrit_file_resource_state.rb +42 -0
  34. data/lib/fetch_util/browser/site_stabilization/gerrit_file_resource_validation_state_script.rb +49 -0
  35. data/lib/fetch_util/browser/site_stabilization/gitea_family_pull_resource_product_state.rb +88 -0
  36. data/lib/fetch_util/browser/site_stabilization/gitea_family_pull_resource_state.rb +109 -0
  37. data/lib/fetch_util/browser/site_stabilization/gitea_family_pull_resources.rb +30 -0
  38. data/lib/fetch_util/browser/site_stabilization/gitea_family_stabilization.rb +60 -0
  39. data/lib/fetch_util/browser/site_stabilization/gitea_family_thread_product_state.rb +74 -0
  40. data/lib/fetch_util/browser/site_stabilization/gitea_family_thread_timeline_state.rb +69 -0
  41. data/lib/fetch_util/browser/site_stabilization/gitea_family_threads.rb +28 -0
  42. data/lib/fetch_util/browser/site_stabilization/gitea_family_visibility_script.rb +28 -0
  43. data/lib/fetch_util/browser/site_stabilization/github_pull_resource_stabilization.rb +31 -0
  44. data/lib/fetch_util/browser/site_stabilization/github_pull_resources.rb +105 -0
  45. data/lib/fetch_util/browser/site_stabilization/github_threads.rb +82 -0
  46. data/lib/fetch_util/browser/site_stabilization/gitlab_merge_request_resource_state.rb +17 -0
  47. data/lib/fetch_util/browser/site_stabilization/gitlab_merge_request_resource_state_script.rb +98 -0
  48. data/lib/fetch_util/browser/site_stabilization/gitlab_merge_request_resource_visibility_script.rb +43 -0
  49. data/lib/fetch_util/browser/site_stabilization/gitlab_merge_request_resources.rb +28 -0
  50. data/lib/fetch_util/browser/site_stabilization/gitlab_repo.rb +3 -11
  51. data/lib/fetch_util/browser/site_stabilization/gitlab_stabilization.rb +33 -0
  52. data/lib/fetch_util/browser/site_stabilization/gitlab_threads.rb +92 -0
  53. data/lib/fetch_util/browser/site_stabilization/social_platforms.rb +19 -38
  54. data/lib/fetch_util/browser/site_stabilization/travel_and_lodging.rb +3 -3
  55. data/lib/fetch_util/browser/site_stabilization.rb +89 -0
  56. data/lib/fetch_util/browser/stabilization/anubis.rb +4 -5
  57. data/lib/fetch_util/browser/stabilization/page_flow.rb +71 -27
  58. data/lib/fetch_util/browser/stabilization/spa_hydration.rb +7 -5
  59. data/lib/fetch_util/browser.rb +88 -17
  60. data/lib/fetch_util/cli.rb +69 -23
  61. data/lib/fetch_util/extractor.rb +8 -2
  62. data/lib/fetch_util/fetcher.rb +144 -43
  63. data/lib/fetch_util/http_redirect_client.rb +161 -0
  64. data/lib/fetch_util/parallel_fetcher.rb +125 -28
  65. data/lib/fetch_util/raw_docs_fallback.rb +158 -21
  66. data/lib/fetch_util/regulatory/cache_store.rb +47 -14
  67. data/lib/fetch_util/regulatory/fetch_records.rb +10 -7
  68. data/lib/fetch_util/regulatory/headers.rb +8 -1
  69. data/lib/fetch_util/regulatory/http_client.rb +2 -97
  70. data/lib/fetch_util/regulatory/orchestration.rb +10 -6
  71. data/lib/fetch_util/regulatory/page.rb +8 -11
  72. data/lib/fetch_util/regulatory/robots.rb +2 -10
  73. data/lib/fetch_util/regulatory/source_selection.rb +13 -3
  74. data/lib/fetch_util/regulatory/tdm_page.rb +9 -4
  75. data/lib/fetch_util/regulatory/tdm_policy.rb +16 -9
  76. data/lib/fetch_util/regulatory/tdm_rep.rb +4 -3
  77. data/lib/fetch_util/regulatory/tdm_support.rb +10 -6
  78. data/lib/fetch_util/regulatory.rb +5 -1
  79. data/lib/fetch_util/request_log.rb +24 -3
  80. data/lib/fetch_util/result.rb +37 -89
  81. data/lib/fetch_util/search_transport.rb +102 -35
  82. data/lib/fetch_util/searcher.rb +15 -13
  83. data/lib/fetch_util/version.rb +1 -1
  84. data/lib/fetch_util.rb +4 -1
  85. metadata +66 -2
@@ -21,6 +21,13 @@ module FetchUtil
21
21
  keyword_init: true
22
22
  )
23
23
 
24
+ COMMON_CMP_SELECTORS = [
25
+ "#onetrust-banner-sdk", "#onetrust-pc-sdk", ".qc-cmp2-container", ".qc-cmp2-summary",
26
+ "#CybotCookiebotDialog", ".cc-window", ".cc_banner", "#cookie-banner", ".cookie-banner",
27
+ "#consent-banner", ".consent-banner", ".fc-consent-root", ".cmp-modal", ".gdpr-banner",
28
+ "#gdpr-consent", ".js-cookies", ".cookie-notice", "#cookieNotice"
29
+ ].freeze
30
+
24
31
  # rubocop:disable Layout/LineLength, Style/RedundantPercentQ
25
32
  DEFAULT_CONSENT_CONFIG = ConsentConfig.new(
26
33
  accept_labels: [
@@ -71,19 +78,13 @@ module FetchUtil
71
78
  context_pattern: %q{(cookie|cookies|privacy|consent|gdpr|ccpa|cmp|onetrust|cookiebot|didomi|quantcast|before you continue|we use cookies and data|device identifiers|personalized ads|personalized content|trusted third party partners?|privacy preference center|your privacy settings|your privacy choices|manage privacy preferences|manage consent preferences|cookie information|cookie list|cookies details|list of partners(?: \(vendors\))?|configurações avançadas de cookies|declaração de cookies|gerenciar cookies|utilizamos cookies|dados pessoais|pliki cookie|datenschutz|données personnelles|datos personales|dati personali|wish to store|access information on your devices|preferenze cookie|クッキー|Cookieプリファレンス|Cookie設定|同意設定|쿠키|동의|개인정보|接受|隐私设置|cookie 偏好设置|файлы cookie|настройки cookie|souhlas|personalizac|soukromí|nastavení souhlasu|kakor|sekretess|samtycke|cookies og data|privatlivs|samtykke|privatliv|personvern|informasjonskapsler|informasjonskapslar|aller media|dine data|galetes|protecció de dades|política de privadesa|ግላዊነት|ኩኪ|ኩኪዎች|pro pokračování vyberte|technické cookies|jakou formou vám máme zobrazovat obsah|evästeet|evästeasetukset|tietosuoja|yksityisyys|hyväksy evästeet|slapukai|privatumas|slapukų nustatymai|kolačinji|приватност|поставки за колачиња|cookie-uri|confidențialitate|setări cookie|protecția datelor|sīkdatnes|sīkfailus|privātums|privātuma iestatījumi|sīkdatņu iestatījumi|sütiket|adatvédelem|adatvédelmi beállítások|süti beállítások|kabul|çerez|gizlilik|лични данни|поверителност|terima|setuju|privasi|ยินยอม|คุกกี้|ความเป็นส่วนตัว)},
72
79
  container_pattern: %q{(cookie|consent|privacy|onetrust|cookiebot|usercentrics|trustarc|didomi|quantcast|sourcepoint|sp_message|uniconsent|osano|gdpr|ccpa)},
73
80
  known_cmp_selectors: [
74
- "#onetrust-banner-sdk", "#onetrust-pc-sdk", ".qc-cmp2-container", ".qc-cmp2-summary",
75
- "#CybotCookiebotDialog", ".cc-window", ".cc_banner", "#cookie-banner", ".cookie-banner",
76
- "#consent-banner", ".consent-banner", ".fc-consent-root", ".cmp-modal", ".gdpr-banner",
77
- "#gdpr-consent", ".js-cookies", ".cookie-notice", "#cookieNotice",
81
+ *COMMON_CMP_SELECTORS,
78
82
  "[id*='sourcepoint']", "[class*='sourcepoint']", "[id*='sp_message']", "[class*='sp_message']",
79
83
  "[id*='uniconsent']", "[class*='uniconsent']", "[id*='uni-consent']", "[class*='uni-consent']",
80
84
  "[id*='osano']", "[class*='osano']"
81
85
  ].freeze,
82
86
  quick_indicator_selectors: [
83
- "#onetrust-banner-sdk", "#onetrust-pc-sdk", ".qc-cmp2-container", ".qc-cmp2-summary",
84
- "#CybotCookiebotDialog", ".cc-window", ".cc_banner", "#cookie-banner", ".cookie-banner",
85
- "#consent-banner", ".consent-banner", ".fc-consent-root", ".cmp-modal", ".gdpr-banner",
86
- "#gdpr-consent", ".js-cookies", ".cookie-notice", "#cookieNotice",
87
+ *COMMON_CMP_SELECTORS,
87
88
  '[id*="onetrust" i]', '[class*="onetrust" i]', '[id*="cookiebot" i]', '[class*="cookiebot" i]',
88
89
  '[id*="usercentrics" i]', '[class*="usercentrics" i]', '[id*="trustarc" i]', '[class*="trustarc" i]',
89
90
  '[id*="didomi" i]', '[class*="didomi" i]', '[id*="quantcast" i]', '[class*="quantcast" i]',
@@ -98,10 +99,7 @@ module FetchUtil
98
99
  '[id*="privacy-preference" i]', '[class*="privacy-preference" i]'
99
100
  ].freeze,
100
101
  container_selectors: [
101
- "#onetrust-banner-sdk", "#onetrust-pc-sdk", ".qc-cmp2-container", ".qc-cmp2-summary",
102
- "#CybotCookiebotDialog", ".cc-window", ".cc_banner", "#cookie-banner", ".cookie-banner",
103
- "#consent-banner", ".consent-banner", ".fc-consent-root", ".cmp-modal", ".gdpr-banner",
104
- "#gdpr-consent", ".js-cookies", ".cookie-notice", "#cookieNotice",
102
+ *COMMON_CMP_SELECTORS,
105
103
  '[id*="cookie" i]', '[class*="cookie" i]', '[id*="consent" i]', '[class*="consent" i]',
106
104
  '[class*="cookie-banner" i]', '[data-testid*="cookie" i]', '[id*="privacy" i]', '[class*="privacy" i]',
107
105
  '[id*="gdpr" i]', '[class*="gdpr" i]', '[id*="ccpa" i]', '[class*="ccpa" i]',
@@ -110,10 +108,7 @@ module FetchUtil
110
108
  '[id*="osano" i]', '[class*="osano" i]'
111
109
  ].freeze,
112
110
  overlay_selectors: [
113
- "#onetrust-banner-sdk", "#onetrust-pc-sdk", ".qc-cmp2-container", ".qc-cmp2-summary",
114
- "#CybotCookiebotDialog", ".cc-window", ".cc_banner", "#cookie-banner", ".cookie-banner",
115
- "#consent-banner", ".consent-banner", ".fc-consent-root", ".cmp-modal", ".gdpr-banner",
116
- "#gdpr-consent", ".js-cookies", ".cookie-notice", "#cookieNotice",
111
+ *COMMON_CMP_SELECTORS,
117
112
  '[id*="cookie" i]', '[class*="cookie" i]', '[id*="consent" i]', '[class*="consent" i]',
118
113
  '[class*="cookie-banner" i]', '[data-testid*="cookie" i]', '[id*="privacy" i]', '[class*="privacy" i]',
119
114
  '[id*="gdpr" i]', '[class*="gdpr" i]', '[id*="ccpa" i]', '[class*="ccpa" i]',
@@ -127,7 +122,7 @@ module FetchUtil
127
122
  button_selectors: 'button, [role="button"], a, input[type="button"], input[type="submit"]'
128
123
  ).freeze
129
124
  # rubocop:enable Layout/LineLength, Style/RedundantPercentQ
130
- private_constant :ConsentConfig, :DEFAULT_CONSENT_CONFIG
125
+ private_constant :ConsentConfig, :COMMON_CMP_SELECTORS, :DEFAULT_CONSENT_CONFIG
131
126
 
132
127
  private
133
128
 
@@ -6,32 +6,59 @@ module FetchUtil
6
6
  module TimingHelpers
7
7
  private
8
8
 
9
- def retry_until_timeout(timeout, interval: 0.2)
10
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
9
+ def monotonic_now
10
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
11
+ end
12
+
13
+ def stabilization_deadline
14
+ monotonic_now + @timeout
15
+ end
16
+
17
+ def retry_until_timeout(timeout, interval: 0.2, deadline: nil)
18
+ now = monotonic_now
19
+ poll_deadline = now + timeout
20
+ poll_deadline = [poll_deadline, deadline].min if deadline
21
+ attempted = false
11
22
 
12
23
  loop do
24
+ now = attempted ? monotonic_now : now
25
+ return false if now > poll_deadline || (attempted && now >= poll_deadline)
26
+
27
+ attempted = true
13
28
  result = yield
14
- return true if result == true
15
- return false if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
29
+ completed_at = monotonic_now
30
+ return true if result == true && completed_at <= poll_deadline
31
+
32
+ remaining = poll_deadline - completed_at
33
+ return false unless remaining.positive?
16
34
 
17
- sleep(result.is_a?(Numeric) ? result : interval)
35
+ delay = result.is_a?(Numeric) ? result : interval
36
+ sleep [delay, remaining].min
18
37
  end
19
38
  end
20
39
 
21
- def capped_timeout(max_timeout)
22
- [@timeout, max_timeout].min
40
+ def capped_timeout(max_timeout, deadline: nil)
41
+ timeout = [@timeout, max_timeout].min
42
+ timeout = [timeout, deadline - monotonic_now].min if deadline
43
+ [timeout, 0].max
23
44
  end
24
45
 
25
- def settle_after_stabilization(max_wait)
26
- sleep [@wait, max_wait].min if @wait.positive?
46
+ def stabilization_time_remaining?(deadline)
47
+ monotonic_now < deadline
27
48
  end
28
49
 
29
- def social_login_phase_pause
30
- if @wait.positive?
31
- settle_after_stabilization(SOCIAL_LOGIN_PHASE_WAIT)
32
- else
33
- sleep SOCIAL_LOGIN_PHASE_WAIT
34
- end
50
+ def sleep_before_deadline(duration, deadline:)
51
+ duration = [duration, deadline - monotonic_now].min
52
+ sleep duration if duration.positive?
53
+ end
54
+
55
+ def settle_after_stabilization(max_wait, deadline: stabilization_deadline)
56
+ sleep_before_deadline([@wait, max_wait].min, deadline: deadline) if @wait.positive?
57
+ end
58
+
59
+ def social_login_phase_pause(deadline: stabilization_deadline)
60
+ duration = @wait.positive? ? [@wait, SOCIAL_LOGIN_PHASE_WAIT].min : SOCIAL_LOGIN_PHASE_WAIT
61
+ sleep_before_deadline(duration, deadline: deadline)
35
62
  end
36
63
  end
37
64
  end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FetchUtil
4
+ class Browser
5
+ module SiteStabilization
6
+ module AzureDevopsPrCommitDetailsScript
7
+ private
8
+
9
+ def azure_devops_pr_commit_details_script
10
+ <<~'JS'
11
+ const AZURE_DEVOPS_COMMIT_DETAIL_CONCURRENCY = 8;
12
+ const completeCommit = async (commit, repositoryId, signal) => {
13
+ const url = new URL(route.prefixPath + "/_apis/git/repositories/" +
14
+ encodeURIComponent(repositoryId) + "/commits/" + encodeURIComponent(commit.commitId), location.origin);
15
+ url.searchParams.set("api-version", "7.1");
16
+ const page = await requestJson(url, signal);
17
+ const detail = page.value;
18
+ if (typeof detail.commitId !== "string" || detail.commitId !== commit.commitId ||
19
+ detail.commentTruncated !== false || typeof detail.comment !== "string") {
20
+ throw new Error("incomplete Azure DevOps commit detail");
21
+ }
22
+ return Object.assign({}, commit, detail, { commentTruncated: false });
23
+ };
24
+
25
+ const completeCommits = async (commits, repositoryId, signal) => {
26
+ const completed = new Array(commits.length);
27
+ let nextIndex = 0;
28
+ const completeNext = async () => {
29
+ while (nextIndex < commits.length) {
30
+ if (signal.aborted) throw new Error("Azure DevOps preparation aborted");
31
+ const index = nextIndex;
32
+ nextIndex += 1;
33
+ completed[index] = await completeCommit(commits[index], repositoryId, signal);
34
+ }
35
+ };
36
+ const workers = Array.from(
37
+ { length: Math.min(AZURE_DEVOPS_COMMIT_DETAIL_CONCURRENCY, commits.length) },
38
+ completeNext
39
+ );
40
+ await Promise.all(workers);
41
+ return completed;
42
+ };
43
+ JS
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FetchUtil
4
+ class Browser
5
+ module SiteStabilization
6
+ module AzureDevopsPrProductStateScript
7
+ private
8
+
9
+ def azure_devops_pr_product_state_script
10
+ <<~'JS'
11
+ const stateKey = "__fetchUtilAzureDevopsPullRequest";
12
+ const pathname = (location.pathname || "").replace(/\/+$/, "");
13
+ const lowerPath = pathname.toLowerCase();
14
+ const marker = lowerPath.lastIndexOf("/_git/");
15
+ if (marker < 1) return { matched: false };
16
+
17
+ const prefixPath = pathname.slice(0, marker);
18
+ const tail = pathname.slice(marker + 6).split("/").filter(Boolean);
19
+ if (tail.length !== 3 || tail[1].toLowerCase() !== "pullrequest" || !/^\d+$/.test(tail[2])) {
20
+ return { matched: false };
21
+ }
22
+
23
+ const decodeSegment = (value) => {
24
+ try { return decodeURIComponent(value); } catch (_error) { return ""; }
25
+ };
26
+ const repository = decodeSegment(tail[0]);
27
+ const prefixParts = prefixPath.split("/").filter(Boolean).map(decodeSegment);
28
+ if (!repository || repository === "." || repository === ".." || !prefixParts.length ||
29
+ prefixParts.some((part) => !part || part === "." || part === "..")) return { matched: false };
30
+
31
+ const number = tail[2];
32
+ const project = prefixParts[prefixParts.length - 1];
33
+ const basePath = prefixPath + "/_git/" + tail[0] + "/pullrequest/" + number;
34
+ const bodyMatch = document.body && document.body.classList.contains("ms-vss-web-vsts-theme");
35
+ const headingMatch = Array.from(document.querySelectorAll('[role="heading"], h1')).some(
36
+ (node) => new RegExp("\\bPull Request\\s+" + number + "\\b", "i").test((node.textContent || "").trim())
37
+ );
38
+ const tabNames = ["overview", "files", "updates", "commits"];
39
+ const tabsMatch = tabNames.every((name) => {
40
+ const tab = document.querySelector("#__bolt-tab-" + name);
41
+ if (!tab) return false;
42
+ try {
43
+ const url = new URL(tab.getAttribute("href"), location.href);
44
+ return url.origin === location.origin && url.pathname === basePath && url.searchParams.get("_a") === name;
45
+ } catch (_error) { return false; }
46
+ });
47
+ const moduleMatch = Array.from(document.scripts).some((script) =>
48
+ /Repos\/Views\/PullRequest|Repos\/Discussion/.test(script.textContent || "")
49
+ );
50
+ const product = !!(bodyMatch && headingMatch && tabsMatch && moduleMatch);
51
+ if (!product) return { matched: true, product: false };
52
+
53
+ const apiPath = prefixPath + "/_apis/git/repositories/" + encodeURIComponent(repository) +
54
+ "/pullRequests/" + number;
55
+ const routeKey = [location.origin, basePath].join(":");
56
+ const existing = window[stateKey];
57
+ if (existing && existing.routeKey === routeKey) {
58
+ return {
59
+ matched: true,
60
+ product: true,
61
+ status: existing.status || "",
62
+ signature: [existing.status, existing.threadCount || 0, existing.commentCount || 0,
63
+ existing.commitCount || 0].join(":")
64
+ };
65
+ }
66
+
67
+ const route = {
68
+ prefixPath: prefixPath,
69
+ project: project,
70
+ repository: repository,
71
+ number: number,
72
+ basePath: basePath,
73
+ apiPath: apiPath
74
+ };
75
+ JS
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FetchUtil
4
+ class Browser
5
+ module SiteStabilization
6
+ module AzureDevopsPrRequestHelpersScript
7
+ private
8
+
9
+ def azure_devops_pr_request_helpers_script
10
+ <<~'JS'
11
+ const requestJson = async (url, signal) => {
12
+ if (url.origin !== location.origin) throw new Error("cross-origin Azure DevOps API route");
13
+ const response = await fetch(url.href, {
14
+ credentials: "same-origin",
15
+ redirect: "error",
16
+ signal: signal,
17
+ headers: { Accept: "application/json", "X-TFS-FedAuthRedirect": "Suppress" }
18
+ });
19
+ if (response.url && new URL(response.url, location.href).origin !== location.origin) {
20
+ throw new Error("cross-origin Azure DevOps API response");
21
+ }
22
+ if (!response.ok) throw new Error("Azure DevOps API returned " + response.status);
23
+ const value = await response.json();
24
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
25
+ throw new Error("invalid Azure DevOps API payload");
26
+ }
27
+ return { response: response, value: value };
28
+ };
29
+ JS
30
+ end
31
+
32
+ def azure_devops_pr_request_lifecycle_script
33
+ <<~'JS'
34
+ const abortControllerKey = "__fetchUtilAzureDevopsPullRequestAbortController";
35
+ const abortController = new AbortController();
36
+ const signal = abortController.signal;
37
+ window[abortControllerKey] = abortController;
38
+ window[stateKey] = { status: "loading", product: true, routeKey: routeKey, route: route };
39
+
40
+ const preparationActive = () => {
41
+ const current = window[stateKey];
42
+ return current && current.status === "loading" && current.routeKey === routeKey &&
43
+ window[abortControllerKey] === abortController;
44
+ };
45
+ const clearAbortController = () => {
46
+ if (window[abortControllerKey] === abortController) delete window[abortControllerKey];
47
+ };
48
+ JS
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FetchUtil
4
+ class Browser
5
+ module SiteStabilization
6
+ module AzureDevopsPrRequestStateScript
7
+ include AzureDevopsPrCommitDetailsScript
8
+ include AzureDevopsPrRequestHelpersScript
9
+
10
+ private
11
+
12
+ def azure_devops_pr_request_state_script
13
+ <<~JS
14
+ #{azure_devops_pr_request_lifecycle_script}
15
+
16
+ #{azure_devops_pr_request_helpers_script}
17
+ #{azure_devops_pr_commit_details_script}
18
+
19
+ const metadataUrl = new URL(route.apiPath, location.origin);
20
+ metadataUrl.searchParams.set("api-version", "7.1");
21
+ requestJson(metadataUrl, signal).then(async ({ value: metadata }) => {
22
+ const repositoryData = metadata.repository || {};
23
+ const projectData = repositoryData.project || {};
24
+ const repositoryId = typeof repositoryData.id === "string" ? repositoryData.id.trim() : "";
25
+ if (String(metadata.pullRequestId || "") !== route.number ||
26
+ String(repositoryData.name || "").toLowerCase() !== route.repository.toLowerCase() ||
27
+ String(projectData.name || "").toLowerCase() !== route.project.toLowerCase() ||
28
+ !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(repositoryId) ||
29
+ !/^public$/i.test(String(projectData.visibility || ""))) {
30
+ throw new Error("Azure DevOps API pull request identity mismatch");
31
+ }
32
+ if (repositoryData.webUrl && new URL(repositoryData.webUrl, location.href).origin !== location.origin) {
33
+ throw new Error("Azure DevOps API repository origin mismatch");
34
+ }
35
+
36
+ const repositoryApiPath = route.prefixPath + "/_apis/git/repositories/" +
37
+ encodeURIComponent(repositoryId) + "/pullRequests/" + route.number;
38
+ route.repositoryApiPath = repositoryApiPath;
39
+ const threadsUrl = new URL(repositoryApiPath + "/threads", location.origin);
40
+ threadsUrl.searchParams.set("api-version", "7.1");
41
+ const threadsPage = await requestJson(threadsUrl, signal);
42
+ const threads = threadsPage.value;
43
+ if (!Array.isArray(threads.value)) throw new Error("invalid Azure DevOps threads payload");
44
+ if (!Number.isInteger(threads.count) || threads.count !== threads.value.length) {
45
+ throw new Error("incomplete Azure DevOps threads payload");
46
+ }
47
+ const threadsContinuation = threadsPage.response.headers &&
48
+ threadsPage.response.headers.get("x-ms-continuationtoken");
49
+ if (threadsContinuation) throw new Error("unexpected Azure DevOps threads continuation");
50
+
51
+ const commits = [];
52
+ const seenTokens = new Set();
53
+ let continuation = "";
54
+ do {
55
+ const commitsUrl = new URL(repositoryApiPath + "/commits", location.origin);
56
+ commitsUrl.searchParams.set("api-version", "7.1");
57
+ commitsUrl.searchParams.set("$top", "1000");
58
+ if (continuation) commitsUrl.searchParams.set("continuationToken", continuation);
59
+ const page = await requestJson(commitsUrl, signal);
60
+ if (!Array.isArray(page.value.value)) throw new Error("invalid Azure DevOps commits payload");
61
+ if (!Number.isInteger(page.value.count) || page.value.count !== page.value.value.length) {
62
+ throw new Error("incomplete Azure DevOps commits payload");
63
+ }
64
+ page.value.value.forEach((commit) => {
65
+ if (!commit || typeof commit.commitId !== "string" || !/^[0-9a-f]{40}$/i.test(commit.commitId)) {
66
+ throw new Error("invalid Azure DevOps commit record");
67
+ }
68
+ commits.push(commit);
69
+ });
70
+ const nextValue = page.response.headers &&
71
+ page.response.headers.get("x-ms-continuationtoken");
72
+ if (nextValue != null && typeof nextValue !== "string") {
73
+ throw new Error("invalid Azure DevOps continuation token");
74
+ }
75
+ const next = (nextValue || "").trim();
76
+ if (next && seenTokens.has(next)) throw new Error("repeated Azure DevOps continuation token");
77
+ if (next) seenTokens.add(next);
78
+ continuation = next;
79
+ } while (continuation);
80
+
81
+ const completedCommits = await completeCommits(commits, repositoryId, signal);
82
+
83
+ const comments = threads.value.reduce((total, thread) =>
84
+ total + (Array.isArray(thread && thread.comments) ? thread.comments.length : 0), 0);
85
+ if (!preparationActive()) return;
86
+ clearAbortController();
87
+ window[stateKey] = {
88
+ status: "ready",
89
+ product: true,
90
+ routeKey: routeKey,
91
+ route: route,
92
+ metadata: metadata,
93
+ threads: threads,
94
+ commits: completedCommits,
95
+ threadCount: threads.value.length,
96
+ commentCount: comments,
97
+ commitCount: completedCommits.length
98
+ };
99
+ }).catch((error) => {
100
+ if (!preparationActive()) return;
101
+ abortController.abort();
102
+ clearAbortController();
103
+ window[stateKey] = {
104
+ status: "failed",
105
+ product: true,
106
+ routeKey: routeKey,
107
+ route: route,
108
+ reason: String(error && error.message || error || "Azure DevOps preparation failed")
109
+ };
110
+ });
111
+
112
+ return { matched: true, product: true, status: "loading", signature: "loading" };
113
+ JS
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FetchUtil
4
+ class Browser
5
+ module SiteStabilization
6
+ module AzureDevopsPrStabilization
7
+ AZURE_DEVOPS_PR_STABILIZATION_PROFILE = {
8
+ host: true,
9
+ path_query: ->(uri) { uri.path.match?(%r{/_git/[^/]+/pullrequest/\d+/?\z}i) },
10
+ strategy: :stabilize_azure_devops_pr,
11
+ fallthrough: true,
12
+ notes: "Prepare product-matched public Azure DevOps pull request REST resources.",
13
+ tests: "spec/fetch_util/browser_stabilization_spec.rb"
14
+ }.freeze
15
+
16
+ private
17
+
18
+ def stabilize_azure_devops_pr(page, deadline: stabilization_deadline)
19
+ state = nil
20
+ ready = retry_until_timeout(
21
+ capped_timeout(12.0, deadline: deadline),
22
+ interval: 0.1,
23
+ deadline: deadline
24
+ ) do
25
+ state = azure_devops_pr_state(page)
26
+ return false if state.is_a?(Hash) && (state["product"] == false || state["status"] == "failed")
27
+
28
+ state.is_a?(Hash) && state["status"] == "ready"
29
+ end
30
+ settle_after_stabilization(0.25, deadline: deadline) if ready
31
+ fail_azure_devops_pr_preparation(page) if !ready && state.is_a?(Hash) && state["status"] == "loading"
32
+ ready
33
+ end
34
+
35
+ def fail_azure_devops_pr_preparation(page)
36
+ safe_evaluate(page, <<~JS, default: nil)
37
+ (() => {
38
+ const state = window.__fetchUtilAzureDevopsPullRequest;
39
+ if (!state || state.status !== "loading") return state;
40
+ const controller = window.__fetchUtilAzureDevopsPullRequestAbortController;
41
+ if (controller && typeof controller.abort === "function") controller.abort();
42
+ delete window.__fetchUtilAzureDevopsPullRequestAbortController;
43
+ window.__fetchUtilAzureDevopsPullRequest = Object.assign({}, state, {
44
+ status: "failed",
45
+ reason: "Azure DevOps REST preparation timed out"
46
+ });
47
+ return window.__fetchUtilAzureDevopsPullRequest;
48
+ })()
49
+ JS
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FetchUtil
4
+ class Browser
5
+ module SiteStabilization
6
+ module AzureDevopsPrState
7
+ include AzureDevopsPrProductStateScript
8
+ include AzureDevopsPrRequestStateScript
9
+
10
+ private
11
+
12
+ def azure_devops_pr_state(page)
13
+ safe_evaluate(page, azure_devops_pr_state_script, default: nil)
14
+ end
15
+
16
+ def azure_devops_pr_state_script
17
+ <<~JS
18
+ (() => {
19
+ #{azure_devops_pr_product_state_script}
20
+ #{azure_devops_pr_request_state_script}
21
+ })()
22
+ JS
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FetchUtil
4
+ class Browser
5
+ module SiteStabilization
6
+ module BitbucketCloudPullDiffProductState
7
+ private
8
+
9
+ def bitbucket_cloud_pull_diff_product_state_script
10
+ <<~'JS'
11
+ const fetchUtilBitbucketDiffProduct = () => {
12
+ const match = location.pathname.match(/^\/([^/]+)\/([^/]+)\/pull-requests\/(\d+)\/diff\/?$/);
13
+ if (!match) return { product: false };
14
+ const decode = (value) => { try { return decodeURIComponent(value); } catch (_error) { return ''; } };
15
+ const route = { workspace: decode(match[1]), repository: decode(match[2]), number: match[3] };
16
+ if (!route.workspace || !route.repository || route.workspace.includes('/') || route.repository.includes('/')) {
17
+ return { product: false };
18
+ }
19
+
20
+ const current = (((window.__initial_state__ || {}).section || {}).repository || {}).currentRepository || {};
21
+ const publicRepository = current.is_private === false || current.isPrivate === false ||
22
+ current.visibility === 'public';
23
+ const runtime = current.type === 'repository' && publicRepository &&
24
+ String(current.full_name || '').toLowerCase() ===
25
+ `${route.workspace}/${route.repository}`.toLowerCase();
26
+ const application = document.querySelector('meta[name="application-name"]');
27
+ const branded = /^Bitbucket$/i.test((application && application.content || '').trim());
28
+ const asset = Array.from(document.querySelectorAll('script[src], link[href]')).some((node) => {
29
+ const value = node.getAttribute('src') || node.getAttribute('href') || '';
30
+ return /(?:frontbucket|bbc-frontbucket-static|bitbucket)[^/]*\/(?:assets|static)\//i.test(value) ||
31
+ /\/(?:assets|static)\/[^?#]*(?:frontbucket|bitbucket)/i.test(value);
32
+ });
33
+ const header = document.querySelector('[data-testid="pr-header"]');
34
+ if (!(runtime && branded && (header || asset))) return { product: false };
35
+
36
+ const apiMeta = document.querySelector('meta[name="bb-api-canon-url"]');
37
+ let apiOrigin;
38
+ try { apiOrigin = new URL(apiMeta && apiMeta.content || '', location.href); } catch (_error) {
39
+ return { product: true, route, error: 'missing Bitbucket API metadata route' };
40
+ }
41
+ if (!/^https?:$/.test(apiOrigin.protocol) || apiOrigin.username || apiOrigin.password) {
42
+ return { product: true, route, error: 'invalid Bitbucket API metadata route' };
43
+ }
44
+ const proxyPrefix = '/!api/2.0';
45
+ const metadataUrl = location.origin + proxyPrefix + '/repositories/' +
46
+ encodeURIComponent(route.workspace) + '/' + encodeURIComponent(route.repository) +
47
+ '/pullrequests/' + route.number;
48
+ return { product: true, route, metadataUrl, proxyPrefix };
49
+ };
50
+ JS
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end