diff --git a/Bugzilla/Config/Github.pm b/Bugzilla/Config/Github.pm index baf5867195..db6559e26d 100644 --- a/Bugzilla/Config/Github.pm +++ b/Bugzilla/Config/Github.pm @@ -17,6 +17,7 @@ sub get_param_list { {name => 'github_pr_linking_enabled', type => 'b', default => 0}, {name => 'github_pr_signature_secret', type => 't', default => ''}, {name => 'github_push_comment_enabled', type => 'b', default => 0}, + {name => 'github_api_token', type => 'p', default => ''}, ); return @param_list; } diff --git a/extensions/GitHubPullRequests/Config.pm b/extensions/GitHubPullRequests/Config.pm new file mode 100644 index 0000000000..e86abc9348 --- /dev/null +++ b/extensions/GitHubPullRequests/Config.pm @@ -0,0 +1,16 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +package Bugzilla::Extension::GitHubPullRequests; + +use 5.10.1; +use strict; +use warnings; + +use constant NAME => 'GitHubPullRequests'; + +__PACKAGE__->NAME; diff --git a/extensions/GitHubPullRequests/Extension.pm b/extensions/GitHubPullRequests/Extension.pm new file mode 100644 index 0000000000..c1db5a2a09 --- /dev/null +++ b/extensions/GitHubPullRequests/Extension.pm @@ -0,0 +1,59 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +package Bugzilla::Extension::GitHubPullRequests; + +use 5.10.1; +use strict; +use warnings; + +use parent qw(Bugzilla::Extension); + +use Bugzilla; + +use constant GITHUB_CONTENT_TYPE => 'text/x-github-pull-request'; + +our $VERSION = '0.01'; + +sub template_before_process { + my ($self, $args) = @_; + my $file = $args->{'file'}; + my $vars = $args->{'vars'}; + + return unless Bugzilla->user->id; + return unless $file =~ /bug_modal\/(header|edit)\.html\.tmpl$/; + + my $bug = exists $vars->{'bugs'} ? $vars->{'bugs'}[0] : $vars->{'bug'}; + return unless $bug; + + # Note: this only counts linked (non-obsolete) PR attachments. The actual + # open/closed/merged state isn't known until the WebService queries GitHub, + # so we can't report an "open" count at template-processing time. + my $has_prs = 0; + my $linked_pr_count = 0; + foreach my $attachment (@{$bug->attachments}) { + next if $attachment->contenttype ne GITHUB_CONTENT_TYPE; + next if $attachment->isobsolete; + + # Don't reveal that a private PR attachment exists to users who aren't + # permitted to see it; the WebService applies the same check. + next if $attachment->isprivate && !Bugzilla->user->is_insider; + $has_prs = 1; + $linked_pr_count++; + } + + $vars->{github_pull_requests} = $has_prs; + $vars->{github_linked_pr_count} = $linked_pr_count; +} + +sub webservice { + my ($self, $args) = @_; + $args->{dispatch}->{GitHubPullRequests} + = 'Bugzilla::Extension::GitHubPullRequests::WebService'; +} + +__PACKAGE__->NAME; diff --git a/extensions/GitHubPullRequests/lib/WebService.pm b/extensions/GitHubPullRequests/lib/WebService.pm new file mode 100644 index 0000000000..87a282f329 --- /dev/null +++ b/extensions/GitHubPullRequests/lib/WebService.pm @@ -0,0 +1,241 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +package Bugzilla::Extension::GitHubPullRequests::WebService; + +use 5.10.1; +use strict; +use warnings; + +use base qw(Bugzilla::WebService); + +use Bugzilla::Bug; +use Bugzilla::Constants; +use Bugzilla::Error; +use Bugzilla::Logging; +use Bugzilla::WebService::Constants; +use Types::Standard qw(-types); +use Type::Params qw(compile); + +use JSON qw(decode_json); +use LWP::UserAgent; + +use constant GITHUB_CONTENT_TYPE => 'text/x-github-pull-request'; +use constant GITHUB_PR_REGEX => qr{^https://github\.com/([^/]+)/([^/]+)/pull/(\d+)/?$}; +use constant GITHUB_API_BASE => 'https://api.github.com'; +use constant GITHUB_API_TIMEOUT => 10; + +# How long (in seconds) to cache a PR's summary in memcached. GitHub's +# unauthenticated rate limit is low (60 req/hr per IP) and authenticated is +# 5000/hr, so caching avoids re-fetching the same PR on every bug view. +use constant GITHUB_CACHE_SECONDS => 300; + +# Cache inaccessible/failed lookups for a shorter period so that persistent +# failures (rate limiting, outages, private repos) don't re-hit GitHub on every +# bug view, while still recovering quickly once the PR becomes reachable again. +use constant GITHUB_ERROR_CACHE_SECONDS => 60; + +use constant READ_ONLY => qw( + bug_pull_requests +); + +use constant PUBLIC_METHODS => qw( + bug_pull_requests +); + +sub bug_pull_requests { + state $check = compile(Object, Dict [bug_id => Int]); + my ($self, $params) = $check->(@_); + + my $user = Bugzilla->login(LOGIN_REQUIRED); + + ThrowUserError('invalid_parameter', {name => 'bug_id', err => 'required'}) + unless $params->{bug_id}; + + my $bug = Bugzilla::Bug->check({id => $params->{bug_id}, cache => 1}); + + my $ua = LWP::UserAgent->new(timeout => GITHUB_API_TIMEOUT); + $ua->agent('BMO-Bugzilla/1.0'); + if (Bugzilla->params->{proxy_url}) { + $ua->proxy('https', Bugzilla->params->{proxy_url}); + } + + # Authenticate when a token is configured. This raises GitHub's API rate + # limit from 60 to 5000 requests/hour, avoiding HTTP 403 failures under load. + my $token = Bugzilla->params->{github_api_token}; + if ($token) { + $ua->default_header('Authorization' => "Bearer $token"); + } + + my @pull_requests; + foreach my $attachment (@{$bug->attachments}) { + next if $attachment->contenttype ne GITHUB_CONTENT_TYPE; + next if $attachment->isobsolete; + + # Don't expose private attachments (and their PR details) to users who + # aren't permitted to see them. + next if $attachment->isprivate && !$user->is_insider; + + my $url = $attachment->data; + $url =~ s/\s+$//; + + my ($owner, $repo, $pr_number) = ($url =~ GITHUB_PR_REGEX); + unless ($owner && $repo && $pr_number) { + WARN("GitHub: could not parse PR URL: $url"); + next; + } + + my $pr_data = _fetch_pull_request($ua, $owner, $repo, $pr_number); + push @pull_requests, $pr_data; + } + + return {pull_requests => \@pull_requests}; +} + +sub _fetch_pull_request { + my ($ua, $owner, $repo, $pr_number) = @_; + + my $url = "https://github.com/$owner/$repo/pull/$pr_number"; + my $api_url = GITHUB_API_BASE . "/repos/$owner/$repo/pulls/$pr_number"; + + my $base = { + url => $url, + number => int($pr_number), + repo => "$owner/$repo", + sortkey => int($pr_number), + }; + + # Return a cached summary if we have a fresh one. + my $cache_key = "github_pr." . $url; + my $cached = Bugzilla->memcached->get_data({key => $cache_key}); + return $cached if defined $cached; + + my $pr_response = _github_get($ua, $api_url); + unless ($pr_response->{ok}) { + WARN("GitHub: failed to fetch PR $url: " . $pr_response->{errmsg}); + return _cache_inaccessible($cache_key, $base); + } + + my $pr = $pr_response->{data}; + + # GitHub should return a JSON object; anything else (an error object, a + # list, etc.) means we can't trust the structure, so fall back gracefully. + unless (ref($pr) eq 'HASH') { + WARN("GitHub: unexpected response shape for PR $url"); + return _cache_inaccessible($cache_key, $base); + } + + my $state; + if ($pr->{draft}) { + $state = 'draft'; + } + elsif ($pr->{merged_at}) { + $state = 'merged'; + } + elsif ($pr->{state} eq 'closed') { + $state = 'closed'; + } + else { + $state = 'open'; + } + + my @labels = map { $_->{name} } @{$pr->{labels} // []}; + + my $reviews_response = _github_get($ua, $api_url . '/reviews'); + my @reviews; + if ($reviews_response->{ok}) { + @reviews = _summarize_reviews($reviews_response->{data}); + } + + my $pr_data = { + %$base, + title => $pr->{title}, + state => $state, + author => ref($pr->{user}) eq 'HASH' ? $pr->{user}{login} : undef, + reviews => \@reviews, + labels => \@labels, + inaccessible => 0, + }; + + Bugzilla->memcached->set_data( + {key => $cache_key, value => $pr_data, expires_in => GITHUB_CACHE_SECONDS}); + + return $pr_data; +} + +sub _cache_inaccessible { + my ($cache_key, $base) = @_; + + my $error_data = {%$base, inaccessible => 1}; + Bugzilla->memcached->set_data({ + key => $cache_key, + value => $error_data, + expires_in => GITHUB_ERROR_CACHE_SECONDS, + }); + + return $error_data; +} + +sub _github_get { + my ($ua, $url) = @_; + + my $response = $ua->get( + $url, + 'Accept' => 'application/vnd.github+json', + 'X-GitHub-Api-Version' => '2022-11-28', + ); + + unless ($response->is_success) { + return {ok => 0, errmsg => $response->status_line}; + } + + my $data = eval { decode_json($response->decoded_content) }; + if ($@) { + return {ok => 0, errmsg => "JSON parse error: $@"}; + } + + return {ok => 1, data => $data}; +} + +sub _summarize_reviews { + my ($reviews) = @_; + + return () unless ref($reviews) eq 'ARRAY'; + + # Keep only the latest review state per reviewer. + # Reviews are returned in chronological order so we can just overwrite. + my %latest; + my @order; + for my $review (@{$reviews}) { + my $login = $review->{user}{login}; + my $state = $review->{state}; + + # COMMENTED is not a conclusive review state; skip it + next if $state eq 'COMMENTED'; + + if (!exists $latest{$login}) { + push @order, $login; + } + $latest{$login} = $state; + } + + return map { {user => $_, state => $latest{$_}} } @order; +} + +sub rest_resources { + return [ + qr{^/githubpr/bug_pull_requests/(\d+)$}, + { + GET => { + method => 'bug_pull_requests', + params => sub { return {bug_id => $_[0]} }, + }, + }, + ]; +} + +1; diff --git a/extensions/GitHubPullRequests/template/en/default/github/header.html.tmpl b/extensions/GitHubPullRequests/template/en/default/github/header.html.tmpl new file mode 100644 index 0000000000..d7ec048d65 --- /dev/null +++ b/extensions/GitHubPullRequests/template/en/default/github/header.html.tmpl @@ -0,0 +1,10 @@ +[%# This Source Code Form is subject to the terms of the Mozilla Public + # License, v. 2.0. If a copy of the MPL was not distributed with this + # file, You can obtain one at http://mozilla.org/MPL/2.0/. + # + # This Source Code Form is "Incompatible With Secondary Licenses", as + # defined by the Mozilla Public License, v. 2.0. + #%] + +[% style_urls.push('extensions/GitHubPullRequests/web/style/github_pull_requests.css') %] +[% javascript_urls.push('extensions/GitHubPullRequests/web/js/github_pull_requests.js') %] diff --git a/extensions/GitHubPullRequests/template/en/default/github/table.html.tmpl b/extensions/GitHubPullRequests/template/en/default/github/table.html.tmpl new file mode 100644 index 0000000000..1f989685e2 --- /dev/null +++ b/extensions/GitHubPullRequests/template/en/default/github/table.html.tmpl @@ -0,0 +1,38 @@ +[%# This Source Code Form is subject to the terms of the Mozilla Public + # License, v. 2.0. If a copy of the MPL was not distributed with this + # file, You can obtain one at http://mozilla.org/MPL/2.0/. + # + # This Source Code Form is "Incompatible With Secondary Licenses", as + # defined by the Mozilla Public License, v. 2.0. + #%] + + + + + + + + + + + + + + + + + + + + + + + + + + +
PRStatusAuthorReviewersRepositoryLabelsTitle
Loading...
Error loading GitHub pull requests: +
+ + +
diff --git a/extensions/GitHubPullRequests/template/en/default/hook/bug_modal/edit-module.html.tmpl b/extensions/GitHubPullRequests/template/en/default/hook/bug_modal/edit-module.html.tmpl new file mode 100644 index 0000000000..81974f9e09 --- /dev/null +++ b/extensions/GitHubPullRequests/template/en/default/hook/bug_modal/edit-module.html.tmpl @@ -0,0 +1,24 @@ +[%# This Source Code Form is subject to the terms of the Mozilla Public + # License, v. 2.0. If a copy of the MPL was not distributed with this + # file, You can obtain one at http://mozilla.org/MPL/2.0/. + # + # This Source Code Form is "Incompatible With Secondary Licenses", as + # defined by the Mozilla Public License, v. 2.0. + #%] + +[% RETURN UNLESS github_pull_requests %] + +[% + gh_subtitle = []; + IF github_linked_pr_count; + gh_subtitle.push(github_linked_pr_count _ " pull request" _ (github_linked_pr_count == 1 ? "" : "s")); + END; +%] + +[% WRAPPER bug_modal/module.html.tmpl + title = "GitHub Pull Requests" + subtitle = gh_subtitle + collapsed = 0 +%] + [% INCLUDE github/table.html.tmpl %] +[% END %] diff --git a/extensions/GitHubPullRequests/template/en/default/hook/bug_modal/header-end.html.tmpl b/extensions/GitHubPullRequests/template/en/default/hook/bug_modal/header-end.html.tmpl new file mode 100644 index 0000000000..a37dc98972 --- /dev/null +++ b/extensions/GitHubPullRequests/template/en/default/hook/bug_modal/header-end.html.tmpl @@ -0,0 +1,13 @@ +[%# This Source Code Form is subject to the terms of the Mozilla Public + # License, v. 2.0. If a copy of the MPL was not distributed with this + # file, You can obtain one at http://mozilla.org/MPL/2.0/. + # + # This Source Code Form is "Incompatible With Secondary Licenses", as + # defined by the Mozilla Public License, v. 2.0. + #%] + +[% + IF github_pull_requests; + PROCESS github/header.html.tmpl; + END; +%] diff --git a/extensions/GitHubPullRequests/web/js/github_pull_requests.js b/extensions/GitHubPullRequests/web/js/github_pull_requests.js new file mode 100644 index 0000000000..9a1598b4aa --- /dev/null +++ b/extensions/GitHubPullRequests/web/js/github_pull_requests.js @@ -0,0 +1,239 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const GitHubPullRequests = { + showClosed: false, + pullRequests: [], + + // Maps PR state to display label and CSS class + STATE_LABELS: { + open: { label: "Open", cls: "gh-state-open" }, + draft: { label: "Draft", cls: "gh-state-draft" }, + merged: { label: "Merged", cls: "gh-state-merged" }, + closed: { label: "Closed", cls: "gh-state-closed" }, + }, + + // Maps GitHub review state to a symbol shown next to reviewer name + REVIEW_SYMBOLS: { + APPROVED: { symbol: "✓", cls: "gh-review-approved", title: "Approved" }, + CHANGES_REQUESTED: { symbol: "✗", cls: "gh-review-changes", title: "Changes Requested" }, + DISMISSED: { symbol: "–", cls: "gh-review-dismissed", title: "Dismissed" }, + PENDING: { symbol: "…", cls: "gh-review-pending", title: "Pending" }, + }, + + isClosedState(state) { + return state === "closed" || state === "merged"; + }, + + buildRow(pr) { + const tr = document.createElement("tr"); + tr.dataset.prUrl = pr.url; + tr.dataset.prState = pr.state || ""; + + if (pr.inaccessible) { + tr.classList.add("github-pr-inaccessible"); + } + + if (pr.state && this.isClosedState(pr.state)) { + tr.classList.add("github-pr-closed"); + if (!this.showClosed) { + tr.classList.add("bz_default_hidden"); + } + } + + // PR number cell + const tdPr = document.createElement("td"); + tdPr.className = "gh-col-pr"; + const prLink = document.createElement("a"); + prLink.href = pr.url; + prLink.target = "_blank"; + prLink.rel = "noopener noreferrer"; + prLink.textContent = `#${pr.number}`; + tdPr.appendChild(prLink); + tr.appendChild(tdPr); + + // Status cell + const tdStatus = document.createElement("td"); + tdStatus.className = "gh-col-status"; + if (pr.inaccessible) { + tdStatus.textContent = "—"; + } else { + const stateInfo = this.STATE_LABELS[pr.state] || {label: pr.state, cls: ""}; + const badge = document.createElement("span"); + badge.className = `gh-state-badge ${stateInfo.cls}`; + badge.textContent = stateInfo.label; + tdStatus.appendChild(badge); + } + tr.appendChild(tdStatus); + + // Author cell + const tdAuthor = document.createElement("td"); + tdAuthor.className = "gh-col-author"; + if (pr.inaccessible || !pr.author) { + tdAuthor.textContent = "—"; + } else { + const authorLink = document.createElement("a"); + authorLink.href = `https://github.com/${pr.author}`; + authorLink.target = "_blank"; + authorLink.rel = "noopener noreferrer"; + authorLink.textContent = pr.author; + tdAuthor.appendChild(authorLink); + } + tr.appendChild(tdAuthor); + + // Reviewers cell + const tdReviewers = document.createElement("td"); + tdReviewers.className = "gh-col-reviewers"; + if (pr.inaccessible || !pr.reviews || pr.reviews.length === 0) { + tdReviewers.textContent = "—"; + } else { + const reviewerList = document.createElement("ul"); + reviewerList.className = "gh-reviewer-list"; + for (const review of pr.reviews) { + const li = document.createElement("li"); + const symbol = this.REVIEW_SYMBOLS[review.state] || {symbol: "?", cls: "", title: review.state}; + const sym = document.createElement("span"); + sym.className = `gh-review-symbol ${symbol.cls}`; + sym.title = symbol.title; + sym.textContent = symbol.symbol; + li.appendChild(sym); + li.appendChild(document.createTextNode(` ${review.user}`)); + reviewerList.appendChild(li); + } + tdReviewers.appendChild(reviewerList); + } + tr.appendChild(tdReviewers); + + // Repository cell + const tdRepo = document.createElement("td"); + tdRepo.className = "gh-col-repo"; + if (pr.inaccessible || !pr.repo) { + tdRepo.textContent = pr.repo || "—"; + } else { + const repoLink = document.createElement("a"); + repoLink.href = `https://github.com/${pr.repo}`; + repoLink.target = "_blank"; + repoLink.rel = "noopener noreferrer"; + repoLink.textContent = pr.repo; + tdRepo.appendChild(repoLink); + } + tr.appendChild(tdRepo); + + // Labels cell + const tdLabels = document.createElement("td"); + tdLabels.className = "gh-col-labels"; + if (pr.inaccessible || !pr.labels || pr.labels.length === 0) { + tdLabels.textContent = "—"; + } else { + const labelList = document.createElement("span"); + labelList.className = "gh-label-list"; + for (const label of pr.labels) { + const span = document.createElement("span"); + span.className = "gh-label"; + span.textContent = label; + labelList.appendChild(span); + } + tdLabels.appendChild(labelList); + } + tr.appendChild(tdLabels); + + // Title cell + const tdTitle = document.createElement("td"); + tdTitle.className = "gh-col-title"; + if (pr.inaccessible) { + const titleLink = document.createElement("a"); + titleLink.href = pr.url; + titleLink.target = "_blank"; + titleLink.rel = "noopener noreferrer"; + titleLink.textContent = `PR #${pr.number}`; + tdTitle.appendChild(titleLink); + const note = document.createElement("span"); + note.className = "gh-inaccessible-note"; + note.textContent = " (details unavailable)"; + tdTitle.appendChild(note); + } else { + const titleLink = document.createElement("a"); + titleLink.href = pr.url; + titleLink.target = "_blank"; + titleLink.rel = "noopener noreferrer"; + titleLink.textContent = pr.title; + tdTitle.appendChild(titleLink); + } + tr.appendChild(tdTitle); + + return tr; + }, + + updateVisibility() { + for (const pr of this.pullRequests) { + const tr = document.querySelector(`tr[data-pr-url="${CSS.escape(pr.url)}"]`); + if (!tr) continue; + if (this.isClosedState(pr.state || "")) { + tr.classList.toggle("bz_default_hidden", !this.showClosed); + } + } + }, + + async onLoad() { + const showClosedCheckbox = document.querySelector("#github-show-closed"); + if (!showClosedCheckbox) return; + + this.showClosed = showClosedCheckbox.checked; + + const tbody = document.querySelector("tbody.github-prs-body"); + if (!tbody) return; + + const loadingRow = tbody.querySelector(".github-loading-row"); + + const displayLoadError = (errStr) => { + const errRow = tbody.querySelector(".github-loading-error-row"); + if (!errRow) return; + errRow.querySelector(".github-load-error-string")?.replaceChildren(errStr); + errRow.classList.remove("bz_default_hidden"); + }; + + try { + const { pull_requests } = await Bugzilla.API.get( + `githubpr/bug_pull_requests/${BUGZILLA.bug_id}` + ); + + this.pullRequests = pull_requests || []; + + if (this.pullRequests.length === 0) { + // Zero results is a normal outcome (e.g. all attachments were obsolete + // or unparseable), not an error - show a neutral message in place. + loadingRow.querySelector("td").textContent = "No pull requests found."; + } else { + for (const pr of this.pullRequests) { + tbody.insertBefore(this.buildRow(pr), loadingRow); + } + loadingRow.classList.add("bz_default_hidden"); + + // Show the closed toggle if any PRs are closed/merged + const hasClosed = this.pullRequests.some(pr => this.isClosedState(pr.state || "")); + if (hasClosed) { + const showClosedTbody = document.querySelector("tbody.github-show-closed"); + if (showClosedTbody) { + showClosedTbody.classList.remove("bz_default_hidden"); + } + } + } + } catch (e) { + console.error(e); + displayLoadError(e.message); + loadingRow.classList.add("bz_default_hidden"); + } + + showClosedCheckbox.addEventListener("click", () => { + this.showClosed = showClosedCheckbox.checked; + this.updateVisibility(); + }); + }, +}; + +window.addEventListener("DOMContentLoaded", () => { + GitHubPullRequests.onLoad(); +}); diff --git a/extensions/GitHubPullRequests/web/style/github_pull_requests.css b/extensions/GitHubPullRequests/web/style/github_pull_requests.css new file mode 100644 index 0000000000..4373757eff --- /dev/null +++ b/extensions/GitHubPullRequests/web/style/github_pull_requests.css @@ -0,0 +1,94 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +.github-pull-requests { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.github-pull-requests th, +.github-pull-requests td { + padding: 4px 8px; + text-align: left; + vertical-align: top; + border-bottom: 1px solid #e0e0e0; +} + +.github-pull-requests thead th { + background-color: #f5f5f5; + font-weight: bold; + white-space: nowrap; +} + +/* State badge */ +.gh-state-badge { + display: inline-block; + padding: 2px 6px; + border-radius: 3px; + font-size: 11px; + font-weight: bold; + white-space: nowrap; +} + +.gh-state-open { background-color: #2da44e; color: #fff; } +.gh-state-draft { background-color: #6e7781; color: #fff; } +.gh-state-merged { background-color: #8250df; color: #fff; } +.gh-state-closed { background-color: #cf222e; color: #fff; } + +/* Reviewer list */ +.gh-reviewer-list { + list-style: none; + margin: 0; + padding: 0; +} + +.gh-reviewer-list li { + white-space: nowrap; +} + +.gh-review-symbol { + font-weight: bold; + font-family: monospace; +} + +.gh-review-approved { color: #2da44e; } +.gh-review-changes { color: #cf222e; } +.gh-review-dismissed { color: #6e7781; } +.gh-review-pending { color: #6e7781; } + +/* Labels */ +.gh-label-list { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.gh-label { + display: inline-block; + padding: 2px 6px; + border-radius: 3px; + font-size: 11px; + background-color: #ddf4ff; + color: #0550ae; + white-space: nowrap; +} + +/* Inaccessible row */ +.github-pr-inaccessible td { + color: #6e7781; +} + +.gh-inaccessible-note { + font-style: italic; + color: #6e7781; +} + +/* Column sizing hints */ +.gh-col-pr { white-space: nowrap; width: 4em; } +.gh-col-status { white-space: nowrap; width: 6em; } +.gh-col-author { white-space: nowrap; } +.gh-col-repo { white-space: nowrap; } +.gh-col-labels { } +.gh-col-title { width: 40%; } diff --git a/template/en/default/admin/params/github.html.tmpl b/template/en/default/admin/params/github.html.tmpl index c21699b392..6a925990c6 100644 --- a/template/en/default/admin/params/github.html.tmpl +++ b/template/en/default/admin/params/github.html.tmpl @@ -40,5 +40,12 @@ github_push_comment_enabled => "Enable the ability for Github pushes to add a comment to a related bug report." + + github_api_token => + "Optional GitHub personal access token used when fetching live pull request " _ + "status for attached pull requests. Providing a token raises GitHub's API " _ + "rate limit from 60 to 5000 requests per hour. A fine-grained token with " _ + "read-only 'Pull requests' access (or a classic token with 'public_repo' " _ + "scope) is sufficient for public repositories." } %]