Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
e996e53
chore: types and mutations
nimish-ks Jan 3, 2026
b1e7ef2
feat: request org scope when setting up auth
nimish-ks Jan 3, 2026
99a6626
feat: add GraphQL query to fetch GitHub organizations
nimish-ks Jan 3, 2026
28b4e85
feat: update CreateGhActionsSync mutation to include orgSync and repo…
nimish-ks Jan 3, 2026
147fc90
feat: enhance GitHub actions sync to support organization-level secre…
nimish-ks Jan 3, 2026
1ab0c60
feat: implement organization management features for GitHub actions, …
nimish-ks Jan 3, 2026
e578944
feat: add GraphQL query and resolver for GitHub organizations in schema
nimish-ks Jan 3, 2026
3e3aabe
feat: add resolver for fetching GitHub organizations in GraphQL
nimish-ks Jan 3, 2026
09ec80b
feat: update CreateGitHubActionsSync mutation to require repo_name fo…
nimish-ks Jan 3, 2026
a983068
feat: enhance ServiceInfo component to display organization sync deta…
nimish-ks Jan 3, 2026
d885021
feat: enhance CreateGhActionsSync component to support organization s…
nimish-ks Jan 3, 2026
90d07c7
Update backend/api/utils/syncing/github/actions.py
nimish-ks Jan 5, 2026
ecc0852
feat: update CreateGitHubActionsSync mutation to require owner for Gi…
nimish-ks Jan 5, 2026
c2476fc
fix: handle oversized GitHub secrets by returning a descriptive error…
nimish-ks Jan 5, 2026
965cff5
fix: update test for GitHub secrets syncing to assert handling of ove…
nimish-ks Jan 5, 2026
ce18083
Update frontend/components/syncing/GitHub/CreateGhActionsSync.tsx
nimish-ks Jan 5, 2026
9078eb9
fix: update default message in FormattedJSON component from 'No detai…
nimish-ks Jan 5, 2026
338c0ca
fix: prevent duplicate GitHub organization syncs and refine repo sync…
nimish-ks Jan 5, 2026
62391d1
fix: not fetch member role
nimish-ks Jan 5, 2026
2383ef8
fix: replace null with undefined for selectedRepo and selectedOrg in …
nimish-ks Jan 5, 2026
280d8d7
feat: add CreateGitHubDependabotSync mutation to support GitHub Depen…
nimish-ks Jan 5, 2026
df156be
feat: implement GitHub Dependabot secrets synchronization functions
nimish-ks Jan 5, 2026
f1bc5be
feat: add GitHub Dependabot configuration to ServiceConfig
nimish-ks Jan 5, 2026
3a12700
feat: implement GitHub Dependabot sync task and update environment sy…
nimish-ks Jan 5, 2026
d78a7e1
feat: add create_gh_dependabot_sync field to Mutation for GitHub Depe…
nimish-ks Jan 5, 2026
220eacd
feat: implement CreateGitHubDependabotSync mutation for enhanced GitH…
nimish-ks Jan 5, 2026
91172bd
feat: add CreateNewGhDependabotSync mutation to support GitHub Depend…
nimish-ks Jan 5, 2026
180dc9d
feat: add CreateNewGhDependabotSync mutation and related types for Gi…
nimish-ks Jan 5, 2026
4c9ee56
feat: add CreateGhDependabotSync mutation for GitHub Dependabot synch…
nimish-ks Jan 5, 2026
05ff6fe
feat: implement CreateGhDependabotSync component for GitHub Dependabo…
nimish-ks Jan 5, 2026
1e14c63
feat: integrate CreateGhDependabotSync into CreateSyncDialog for GitH…
nimish-ks Jan 5, 2026
7f3691f
feat: enhance ServiceInfo component to display organization and repos…
nimish-ks Jan 5, 2026
e4f440c
test: add unit tests for GitHub Dependabot secrets synchronization an…
nimish-ks Jan 5, 2026
5900c6f
refactor: remove unused service_config variable from CreateGitHubDepe…
nimish-ks Jan 5, 2026
62e04b6
refactor: remove unused data variable from CreateGhDependabotSync mut…
nimish-ks Jan 5, 2026
bda63e0
Update frontend/components/syncing/GitHub/CreateGhDependabotSync.tsx
nimish-ks Jan 5, 2026
c364131
Update backend/backend/graphene/mutations/syncing.py
nimish-ks Jan 5, 2026
cb3aed2
Merge branch 'main' into feat--sync-secrets-github-organization
nimish-ks Jan 6, 2026
0d0e5e1
Merge branch 'main' into feat--sync-secrets-github-organization
nimish-ks Jan 11, 2026
be60fe4
fix: update app host retrieval in GitHub actions normalization
nimish-ks Jan 11, 2026
033bf69
Merge branch 'main' into feat--sync-secrets-github-organization
rohan-chaturvedi Jan 14, 2026
2762078
fix: ensure sync jobs gracefully handle cases where credentials are r…
rohan-chaturvedi Jan 16, 2026
c9414c9
feat: add permission checks for accessing GitHub environments and org…
rohan-chaturvedi Jan 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 27 additions & 12 deletions backend/api/tasks/syncing.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from api.utils.syncing.github.actions import (
get_gh_actions_credentials,
sync_github_secrets,
sync_github_org_secrets,
)
from api.utils.syncing.vault.main import sync_vault_secrets
from api.utils.syncing.nomad.main import sync_nomad_secrets
Expand Down Expand Up @@ -255,19 +256,33 @@ def perform_cloudflare_pages_sync(environment_sync):
def perform_github_actions_sync(environment_sync):

access_token, api_host = get_gh_actions_credentials(environment_sync)
repo_name = environment_sync.options.get("repo_name")
repo_owner = environment_sync.options.get("owner")
environment_name = environment_sync.options.get("environment_name")
is_org_sync = environment_sync.options.get("org_sync", False)

handle_sync_event(
environment_sync,
sync_github_secrets,
access_token,
repo_name,
repo_owner,
api_host,
environment_name,
)
if is_org_sync:
org = environment_sync.options.get("org")
visibility = environment_sync.options.get("visibility", "all")
handle_sync_event(
environment_sync,
sync_github_org_secrets,
access_token,
org,
api_host,
visibility,
)
else:
repo_name = environment_sync.options.get("repo_name")
repo_owner = environment_sync.options.get("owner")
environment_name = environment_sync.options.get("environment_name")

handle_sync_event(
environment_sync,
sync_github_secrets,
access_token,
repo_name,
repo_owner,
api_host,
environment_name,
)


@job("default", timeout=DEFAULT_TIMEOUT)
Expand Down
156 changes: 156 additions & 0 deletions backend/api/utils/syncing/github/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ class GitHubRepoType(ObjectType):
type = graphene.String()


class GitHubOrgType(ObjectType):
name = graphene.String()
role = graphene.String()


def normalize_api_host(api_host):
if not api_host or api_host.strip() == "":
api_host = GITHUB_CLOUD_API_URL
Expand Down Expand Up @@ -87,6 +92,59 @@ def serialize_repos(repos):
return all_repos


def list_orgs(credential_id):
ProviderCredentials = apps.get_model("api", "ProviderCredentials")

pk, sk = get_server_keypair()
credential = ProviderCredentials.objects.get(id=credential_id)

access_token = decrypt_asymmetric(
credential.credentials["access_token"], sk.hex(), pk.hex()
)

api_host = GITHUB_CLOUD_API_URL
if "host" in credential.credentials:
api_host = decrypt_asymmetric(
credential.credentials["api_url"], sk.hex(), pk.hex()
)

api_host = normalize_api_host(api_host)

headers = {
"Authorization": f"Bearer {access_token}",
"Accept": "application/vnd.github+json",
}

all_orgs = []
page = 1

while True:
response = requests.get(
f"{api_host}/user/orgs?per_page=100&page={page}", headers=headers
)
if response.status_code != 200:
raise Exception(f"Error fetching organizations: {response.text}")

orgs_on_page = response.json()
if not orgs_on_page:
break

for org in orgs_on_page:
# Get org membership to determine role
membership_response = requests.get(
f"{api_host}/user/memberships/orgs/{org['login']}", headers=headers
)
role = "member"
if membership_response.status_code == 200:
role = membership_response.json().get("role", "member")
Copy link

Copilot AI Jan 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error handling for the membership API call silently defaults to 'member' role if the request fails for any reason (line 139). This masks potential issues and could lead to incorrect role information being displayed. Consider logging the error or handling specific error cases (403, 404) differently to provide better visibility into failures.

Copilot uses AI. Check for mistakes.

all_orgs.append({"name": org["login"], "role": role})

page += 1

return all_orgs


def get_gh_actions_credentials(environment_sync):
pk, sk = get_server_keypair()

Expand Down Expand Up @@ -301,3 +359,101 @@ def sync_github_secrets(

traceback.print_exc()
return False, {"message": f"An unexpected error occurred: {str(e)}"}


def get_all_org_secrets(org, headers, api_host=GITHUB_CLOUD_API_URL):
api_host = normalize_api_host(api_host)
all_secrets = []
page = 1
while True:
response = requests.get(
f"{api_host}/orgs/{org}/actions/secrets?page={page}",
headers=headers,
)
if response.status_code != 200 or not response.json().get("secrets"):
break
all_secrets.extend(response.json()["secrets"])
page += 1
return all_secrets


def sync_github_org_secrets(
secrets,
access_token,
org,
api_host=GITHUB_CLOUD_API_URL,
visibility="all",
):
api_host = normalize_api_host(api_host)

try:
if not check_rate_limit(access_token, api_host):
return False, {"message": "Rate limit exceeded"}

headers = {
"Authorization": f"Bearer {access_token}",
"Accept": "application/vnd.github+json",
}

public_key_url = f"{api_host}/orgs/{org}/actions/secrets/public-key"

public_key_response = requests.get(public_key_url, headers=headers)
if public_key_response.status_code != 200:
if public_key_response.status_code == 404:
return False, {
"response_code": public_key_response.status_code,
"message": "Unable to access organization. Please verify the organization exists and your access token has the required permissions (admin:org scope).",
}
return False, {
"response_code": public_key_response.status_code,
"message": f"Failed to fetch organization public key: {public_key_response.text}",
}

public_key = public_key_response.json()
key_id = public_key["key_id"]
public_key_value = public_key["key"]

local_secrets = {k: v for k, v, _ in secrets}
existing_secrets = get_all_org_secrets(org, headers, api_host)
existing_secret_names = {secret["name"] for secret in existing_secrets}

for key, value in local_secrets.items():
encrypted_value = encrypt_secret(public_key_value, value)
if len(encrypted_value) > 64 * 1024:
continue # Skip oversized secret

secret_data = {
"encrypted_value": encrypted_value,
"key_id": key_id,
"visibility": visibility,
}
secret_url = f"{api_host}/orgs/{org}/actions/secrets/{key}"
response = requests.put(secret_url, headers=headers, json=secret_data)

if response.status_code not in [201, 204]:
return False, {
"response_code": response.status_code,
"message": f"Error syncing secret '{key}': {response.text}",
}

for secret_name in existing_secret_names:
if secret_name not in local_secrets:
delete_url = f"{api_host}/orgs/{org}/actions/secrets/{secret_name}"
delete_response = requests.delete(delete_url, headers=headers)
if delete_response.status_code != 204:
return False, {
"response_code": delete_response.status_code,
"message": f"Error deleting secret '{secret_name}': {delete_response.text}",
}

return True, {"message": "Organization secrets synced successfully"}

except requests.RequestException as e:
return False, {"message": f"HTTP request error: {str(e)}"}
except json.JSONDecodeError:
return False, {"message": "Error decoding JSON response"}
except Exception as e:
import traceback

traceback.print_exc()
return False, {"message": f"An unexpected error occurred: {str(e)}"}
35 changes: 30 additions & 5 deletions backend/backend/graphene/mutations/syncing.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,15 +264,27 @@ class Arguments:
env_id = graphene.ID()
path = graphene.String()
credential_id = graphene.ID()
repo_name = graphene.String()
repo_name = graphene.String(required=False)
owner = graphene.String()
environment_name = graphene.String(required=False)
org_sync = graphene.Boolean(required=False)
repo_visibility = graphene.String(required=False)

sync = graphene.Field(EnvironmentSyncType)

@classmethod
def mutate(
cls, root, info, env_id, path, credential_id, repo_name, owner, environment_name=None
cls,
root,
info,
env_id,
path,
credential_id,
owner,
repo_name=None,
environment_name=None,
org_sync=False,
repo_visibility="all",
):
service_id = "github_actions"
service_config = ServiceConfig.get_service_config(service_id)
Expand All @@ -285,16 +297,29 @@ def mutate(
if not user_can_access_app(info.context.user.userId, env.app.id):
raise GraphQLError("You don't have access to this app")

sync_options = {"repo_name": repo_name, "owner": owner}
if environment_name:
sync_options["environment_name"] = environment_name
if org_sync:
sync_options = {
"org": owner,
"org_sync": True,
"visibility": repo_visibility or "all",
}
else:
if not repo_name:
raise GraphQLError("Repository name is required for repository syncs")
sync_options = {"repo_name": repo_name, "owner": owner}
if environment_name:
sync_options["environment_name"] = environment_name

existing_syncs = EnvironmentSync.objects.filter(
environment__app_id=env.app.id, service=service_id, deleted_at=None
)

for es in existing_syncs:
if es.options == sync_options:
if org_sync:
raise GraphQLError(
"A sync already exists for this GitHub organization!"
)
raise GraphQLError("A sync already exists for this GitHub repo!")

sync = EnvironmentSync.objects.create(
Expand Down
10 changes: 9 additions & 1 deletion backend/backend/graphene/queries/syncing.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
)
from api.services import Providers, ServiceConfig
from api.utils.syncing.aws.secrets_manager import list_aws_secrets
from api.utils.syncing.github.actions import list_repos, list_environments
from api.utils.syncing.github.actions import list_repos, list_environments, list_orgs
from api.utils.syncing.vault.main import test_vault_creds
from api.utils.syncing.nomad.main import test_nomad_creds
from api.utils.syncing.gitlab.main import list_gitlab_groups, list_gitlab_projects
Expand Down Expand Up @@ -199,6 +199,14 @@ def resolve_github_environments(root, info, credential_id, owner, repo_name):
raise GraphQLError(ex)


def resolve_gh_orgs(root, info, credential_id):
try:
orgs = list_orgs(credential_id)
return orgs
except Exception as ex:
raise GraphQLError(ex)


def resolve_test_vault_creds(root, info, credential_id):
try:
valid = test_vault_creds(credential_id)
Expand Down
8 changes: 7 additions & 1 deletion backend/backend/schema.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from api.utils.syncing.cloudflare.pages import CloudFlarePagesType
from api.utils.syncing.cloudflare.workers import CloudflareWorkerType
from api.utils.syncing.aws.secrets_manager import AWSSecretType
from api.utils.syncing.github.actions import GitHubRepoType
from api.utils.syncing.github.actions import GitHubRepoType, GitHubOrgType
from api.utils.syncing.gitlab.main import GitLabGroupType, GitLabProjectType
from api.utils.syncing.railway.main import RailwayProjectType
from api.utils.syncing.render.main import RenderEnvGroupType, RenderServiceType
Expand Down Expand Up @@ -71,6 +71,7 @@
from .graphene.queries.syncing import (
resolve_aws_secret_manager_secrets,
resolve_gh_repos,
resolve_gh_orgs,
resolve_github_environments,
resolve_gitlab_projects,
resolve_gitlab_groups,
Expand Down Expand Up @@ -395,6 +396,10 @@ class Query(graphene.ObjectType):
GitHubRepoType,
credential_id=graphene.ID(),
)
github_orgs = graphene.List(
GitHubOrgType,
credential_id=graphene.ID(),
)
github_environments = graphene.List(
graphene.String,
credential_id=graphene.ID(),
Expand Down Expand Up @@ -475,6 +480,7 @@ class Query(graphene.ObjectType):
resolve_aws_secrets = resolve_aws_secret_manager_secrets

resolve_github_repos = resolve_gh_repos
resolve_github_orgs = resolve_gh_orgs
resolve_github_environments = resolve_github_environments

resolve_gitlab_projects = resolve_gitlab_projects
Expand Down
Loading