Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 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
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
7 changes: 7 additions & 0 deletions backend/api/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,13 @@ class ServiceConfig:
"provider": Providers.GITHUB,
"resource_type": "repo",
}

GITHUB_DEPENDABOT = {
"id": "github_dependabot",
"name": "GitHub Dependabot",
"provider": Providers.GITHUB,
"resource_type": "repo",
}

GITLAB_CI = {
"id": "gitlab_ci",
Expand Down
83 changes: 71 additions & 12 deletions backend/api/tasks/syncing.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@
from api.utils.syncing.github.actions import (
get_gh_actions_credentials,
sync_github_secrets,
sync_github_org_secrets,
)
from api.utils.syncing.github.dependabot import (
sync_github_dependabot_secrets,
sync_github_dependabot_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 @@ -84,6 +89,15 @@ def trigger_sync_tasks(env_sync):

EnvironmentSyncEvent.objects.create(id=job_id, env_sync=env_sync)

elif env_sync.service == ServiceConfig.GITHUB_DEPENDABOT["id"]:
env_sync.status = EnvironmentSync.IN_PROGRESS
env_sync.save()

job = perform_github_dependabot_sync.delay(env_sync)
job_id = job.get_id()

EnvironmentSyncEvent.objects.create(id=job_id, env_sync=env_sync)

elif env_sync.service == ServiceConfig.HASHICORP_VAULT["id"]:
env_sync.status = EnvironmentSync.IN_PROGRESS
env_sync.save()
Expand Down Expand Up @@ -255,19 +269,64 @@ 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)
def perform_github_dependabot_sync(environment_sync):

access_token, api_host = get_gh_actions_credentials(environment_sync)
is_org_sync = environment_sync.options.get("org_sync", False)

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

handle_sync_event(
environment_sync,
sync_github_dependabot_secrets,
access_token,
repo_name,
repo_owner,
api_host,
)


@job("default", timeout=DEFAULT_TIMEOUT)
Expand Down
166 changes: 164 additions & 2 deletions backend/api/utils/syncing/github/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,17 @@ 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

if settings.APP_HOST == "cloud":
app_host = getattr(settings, "APP_HOST", None)
if app_host == "cloud":
validate_url_is_safe(api_host)

stripped_host = api_host.rstrip("/")
Expand Down Expand Up @@ -92,6 +98,52 @@ 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:
role = org.get("role", "member")
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 @@ -265,7 +317,10 @@ def sync_github_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
message = (
f"Secret '{key}' is too large to sync. GitHub Actions has a limit of 64KB for secrets."
)
return False, {"message": message}

secret_data = {"encrypted_value": encrypted_value, "key_id": key_id}
if environment_name:
Expand Down Expand Up @@ -306,3 +361,110 @@ 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:
break
try:
data = response.json()
except json.JSONDecodeError:
break
if not data.get("secrets"):
break
all_secrets.extend(data["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:
message = (
f"Secret '{key}' is too large to sync. GitHub Actions has a limit of 64KB for secrets."
)
return False, {"message": message}

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)}"}
Loading