Skip to content

Commit 15d2e82

Browse files
committed
fix: use dedicated PAT for org membership check
1 parent 4df1cf0 commit 15d2e82

6 files changed

Lines changed: 94 additions & 30 deletions

File tree

README.md

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -172,17 +172,21 @@ The GitHub to JIRA Issue Sync workflow requires the configuration of specific en
172172

173173
Below is a detailed table outlining the necessary configurations:
174174

175-
| Variable/Secret | Description | Requirement |
176-
| ----------------- | -------------------------------------------------------------------------------------------- | ----------- |
177-
| `JIRA_PROJECT` | Specifies the Jira project to synchronize with. | Mandatory |
178-
| `JIRA_ISSUE_TYPE` | Specifies the JIRA issue type for new issues. Defaults to "Task" if not set. | Optional |
179-
| `JIRA_COMPONENT` | The name of a JIRA component to add to every synced issue. The component must exist in JIRA. | Optional |
180-
| `WEBHOOK_URL` | URL to be called after successful action | Optional |
181-
| `JIRA_URL` | The main URL of your JIRA instance. | Inherited |
182-
| `JIRA_USER` | The username used for logging into JIRA (basic auth). | Inherited |
183-
| `JIRA_PASS` | The JIRA token (for token auth) or password (for basic auth) used for logging in. | Inherited |
184-
185-
- **GitHub Organizational Secrets**: `JIRA_URL`, `JIRA_USER`, `JIRA_PASS` - These secrets are **inherited from the GitHub organizational secrets, as they are common to all projects within the organization**.
175+
| Variable/Secret | Description | Requirement |
176+
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
177+
| `JIRA_PROJECT` | Specifies the Jira project to synchronize with. | Mandatory |
178+
| `JIRA_ISSUE_TYPE` | Specifies the JIRA issue type for new issues. Defaults to "Task" if not set. | Optional |
179+
| `JIRA_COMPONENT` | The name of a JIRA component to add to every synced issue. The component must exist in JIRA. | Optional |
180+
| `WEBHOOK_URL` | URL to be called after successful action | Optional |
181+
| `JIRA_URL` | The main URL of your JIRA instance. | Inherited |
182+
| `JIRA_USER` | The username used for logging into JIRA (basic auth). | Inherited |
183+
| `JIRA_PASS` | The JIRA token (for token auth) or password (for basic auth) used for logging in. | Inherited |
184+
| `GITHUB_ORG_READ_TOKEN` | GitHub PAT with `read:org` scope. Required to skip PRs from org members whose membership is **private**. Without it, those PRs are incorrectly synced to Jira. | Inherited |
185+
186+
- **GitHub Organizational Secrets**: `JIRA_URL`, `JIRA_USER`, `JIRA_PASS`, `SYNC_JIRA_ORG_READ_TOKEN` - These secrets are **inherited from the GitHub organizational secrets, as they are common to all projects within the organization**.
187+
188+
> \[!NOTE\]
189+
> `SYNC_JIRA_ORG_READ_TOKEN` is a one-time org-level setup: create a Classic PAT with `read:org` scope on a service account, store it at [github.com/organizations/espressif/settings/secrets/actions](https://github.com/organizations/espressif/settings/secrets/actions) (access: *All repositories*), then add `GITHUB_ORG_READ_TOKEN: ${{ secrets.SYNC_JIRA_ORG_READ_TOKEN }}` to the `env:` block in each repo's `sync-jira.yml`.
186190

187191
> \[!WARNING\]
188192
> Do not to set secrets at the individual repository level to avoid conflicts and ensure a unified configuration across all projects.

action.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ runs:
6969
python sync_jira_actions/sync_to_jira.py
7070
env:
7171
GITHUB_TOKEN: ${{ env.GITHUB_TOKEN }} # Needs to be passed from caller workflow; by ENV (secure), not by input
72+
GITHUB_ORG_READ_TOKEN: ${{ env.GITHUB_ORG_READ_TOKEN }} # Optional PAT with read:org scope; required to detect private org membership (GITHUB_TOKEN lacks this scope)
7273
JIRA_PASS: ${{ env.JIRA_PASS }} # Needs to be passed from caller workflow; by ENV (secure), not by input
7374
JIRA_URL: ${{ env.JIRA_URL }} # Needs to be passed from caller workflow; by ENV (secure), not by input
7475
JIRA_USER: ${{ env.JIRA_USER }} # Needs to be passed from caller workflow; by ENV (secure), not by input

docs/sync-jira.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ jobs:
4646
cron_job: ${{ github.event_name == 'schedule' && 'true' || '' }}
4747
env:
4848
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
49+
# PAT with read:org scope; stored as an org-level secret in github.com/organizations/espressif/settings/secrets/actions
50+
# Required to detect private org membership. Without it, PRs from members with private membership are synced to Jira.
51+
GITHUB_ORG_READ_TOKEN: ${{ secrets.SYNC_JIRA_ORG_READ_TOKEN }}
4952
JIRA_PASS: ${{ secrets.JIRA_PASS }}
5053
JIRA_PROJECT: IDFSYNTEST # <---- update for your Jira project
5154
JIRA_COMPONENT: GitHub

sync_jira_actions/sync_pr.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,33 @@
1515
# limitations under the License.
1616
#
1717
import os
18+
import warnings
1819

1920
from github import Github
2021
from github import GithubException
2122
from sync_issue import _create_jira_issue
2223
from sync_issue import _find_jira_issue
2324

2425

26+
def _get_org_github_client(github=None):
27+
"""
28+
Return a GitHub client using GITHUB_ORG_READ_TOKEN if set, otherwise GITHUB_TOKEN.
29+
GITHUB_ORG_READ_TOKEN should be a PAT with read:org scope so that private org
30+
membership can be checked. Falls back to the provided client (or GITHUB_TOKEN)
31+
which can only see public org membership.
32+
"""
33+
org_token = os.environ.get('GITHUB_ORG_READ_TOKEN')
34+
if org_token:
35+
return Github(org_token)
36+
warnings.warn(
37+
'GITHUB_ORG_READ_TOKEN is not set. Org membership check will only work for '
38+
'users with public org membership. Set GITHUB_ORG_READ_TOKEN to a PAT with '
39+
'read:org scope to fix this.',
40+
stacklevel=2,
41+
)
42+
return github if github is not None else Github(os.environ['GITHUB_TOKEN'])
43+
44+
2545
def _is_collaborator_or_org_member(github, repo, username):
2646
"""
2747
Check if user is a collaborator or organization member.
@@ -30,9 +50,10 @@ def _is_collaborator_or_org_member(github, repo, username):
3050
if repo.has_in_collaborators(username):
3151
return 'collaborator'
3252
if repo.owner.type == 'Organization':
33-
org = github.get_organization(repo.owner.login)
53+
github_org = _get_org_github_client(github)
54+
org = github_org.get_organization(repo.owner.login)
3455
try:
35-
if org.has_in_members(github.get_user(username)):
56+
if org.has_in_members(github_org.get_user(username)):
3657
return 'organization member'
3758
except GithubException:
3859
print(f'WARNING ⚠️ Could not check org membership for @{username}, treating as external contributor')

sync_jira_actions/sync_to_jira.py

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
import os
1919

2020
from github import Github
21-
from github import GithubException
2221
from jira import JIRA
2322
from sync_issue import handle_comment_created
2423
from sync_issue import handle_comment_deleted
@@ -131,19 +130,12 @@ def main(): # noqa
131130
gh_issue = event['issue']
132131
is_pr = 'pull_request' in gh_issue
133132
if is_pr:
134-
user_type = None
135-
if repo.has_in_collaborators(gh_issue['user']['login']):
136-
user_type = 'collaborator'
137-
elif repo.owner.type == 'Organization':
138-
org = github.get_organization(repo.owner.login)
139-
try:
140-
if org.has_in_members(github.get_user(gh_issue['user']['login'])):
141-
user_type = 'organization member'
142-
except GithubException:
143-
username = gh_issue['user']['login']
144-
print(f'WARNING ⚠️ Could not check org membership for @{username},' ' treating as external contributor')
133+
from sync_pr import _is_collaborator_or_org_member
134+
135+
username = gh_issue['user']['login']
136+
user_type = _is_collaborator_or_org_member(github, repo, username)
145137
if user_type:
146-
print(f'Skipping PR sync - author @{gh_issue["user"]["login"]} is a {user_type}')
138+
print(f'Skipping PR sync - author @{username} is a {user_type}')
147139
return
148140

149141
action_handlers = {

tests/test_sync_pr.py

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,23 +106,66 @@ def test_sync_remain_prs_skips_collaborators(sync_pr_module, mock_sync_issue, mo
106106
assert mock_find_jira_issue.call_count == 0
107107

108108

109-
def test_is_collaborator_or_org_member_handles_bot_accounts(sync_pr_module):
109+
def test_is_collaborator_or_org_member_handles_bot_accounts(sync_pr_module, monkeypatch):
110110
"""Test that _is_collaborator_or_org_member returns None for bot accounts"""
111111
from github import GithubException
112112

113+
monkeypatch.delenv('GITHUB_ORG_READ_TOKEN', raising=False)
114+
113115
mock_github_instance = MagicMock()
116+
mock_github_instance.get_user.side_effect = GithubException(404, 'Not Found', None)
114117
mock_repo = MagicMock()
115118
mock_repo.has_in_collaborators.return_value = False
116119
mock_repo.owner.type = 'Organization'
117120
mock_repo.owner.login = 'fake-org'
118121

119-
mock_github_instance.get_user.side_effect = GithubException(404, 'Not Found', None)
120-
121-
result = sync_pr_module._is_collaborator_or_org_member(mock_github_instance, mock_repo, 'copilot[bot]')
122+
with pytest.warns(UserWarning):
123+
result = sync_pr_module._is_collaborator_or_org_member(
124+
mock_github_instance, mock_repo, 'copilot[bot]'
125+
)
122126

123127
assert result is None
124128

125129

130+
def test_is_collaborator_or_org_member_uses_org_read_token(sync_pr_module, monkeypatch):
131+
"""Test that _is_collaborator_or_org_member uses GITHUB_ORG_READ_TOKEN when available"""
132+
monkeypatch.setenv('GITHUB_ORG_READ_TOKEN', 'org-read-token')
133+
134+
mock_github_instance = MagicMock()
135+
mock_repo = MagicMock()
136+
mock_repo.has_in_collaborators.return_value = False
137+
mock_repo.owner.type = 'Organization'
138+
mock_repo.owner.login = 'fake-org'
139+
140+
with patch('sync_pr.Github') as MockGithub:
141+
mock_org_github = MagicMock()
142+
mock_org = MagicMock()
143+
mock_org.has_in_members.return_value = True
144+
mock_org_github.get_organization.return_value = mock_org
145+
MockGithub.return_value = mock_org_github
146+
147+
result = sync_pr_module._is_collaborator_or_org_member(mock_github_instance, mock_repo, 'avgustina')
148+
149+
MockGithub.assert_called_once_with('org-read-token')
150+
151+
assert result == 'organization member'
152+
153+
154+
def test_is_collaborator_or_org_member_warns_without_org_read_token(sync_pr_module, monkeypatch):
155+
"""Test that a warning is emitted when GITHUB_ORG_READ_TOKEN is not set"""
156+
monkeypatch.delenv('GITHUB_ORG_READ_TOKEN', raising=False)
157+
158+
mock_github_instance = MagicMock()
159+
mock_github_instance.get_organization.return_value.has_in_members.return_value = False
160+
mock_repo = MagicMock()
161+
mock_repo.has_in_collaborators.return_value = False
162+
mock_repo.owner.type = 'Organization'
163+
mock_repo.owner.login = 'fake-org'
164+
165+
with pytest.warns(UserWarning, match='GITHUB_ORG_READ_TOKEN'):
166+
sync_pr_module._is_collaborator_or_org_member(mock_github_instance, mock_repo, 'testuser')
167+
168+
126169
def test_sync_remain_prs_handles_bot_accounts(sync_pr_module, mock_sync_issue, mock_github):
127170
"""Test that PRs from bot accounts (e.g. copilot[bot]) don't crash the sync"""
128171
mock_jira = MagicMock()

0 commit comments

Comments
 (0)