Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
30 changes: 30 additions & 0 deletions client/app/components/DocInfoCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,15 @@
{{ rfcToBe.repository }}
</a>
<span v-else>(none)</span>
<BaseButton
v-if="!rfcToBe.repository && !props.isReadOnly"
class="ml-2"
size="xs"
btn-type="outline"
:disabled="isCreatingRepo"
@click="createRepo">
Create repo
</BaseButton>
</div>
<p
v-if="rfcToBe.repository"
Expand Down Expand Up @@ -656,6 +665,27 @@ import { formatBlockingReason } from '~/utils/finalreviews'

const datatrackerLinks = useDatatrackerLinks()

const isCreatingRepo = ref(false)

// Retries repository creation after it failed on import. The work runs in the
// background: on success the repository field is filled, a failure is notified.
async function createRepo() {
if (!props.rfcToBe?.name) return
isCreatingRepo.value = true
try {
await api.documentsCreateRepo({ draftName: props.rfcToBe.name })
snackbar.add({
type: 'success',
title: 'Creating repo',
text: 'It will show here once created; you will get a notification if it fails.'
})
} catch (error) {
snackbarForErrors({ snackbar, error, defaultTitle: 'Could not start creating the repo' })
} finally {
isCreatingRepo.value = false
}
}

type PatchPersonField = 'streamManagerId' | 'shepherdId' | 'iesgContactId'

const patchPerson = async (field: PatchPersonField, personId: number | null) => {
Expand Down
14 changes: 11 additions & 3 deletions client/app/pages/notifications.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,17 @@
<ul v-else class="divide-y divide-gray-100 dark:divide-gray-700">
<li v-for="n in items" :key="n.id">
<Anchor
:href="`/docs/${n.draftName}/assignments`"
:href="documentPathBuilder({ name: n.draftName })"
class="flex items-start gap-3 py-3 px-2 -mx-2 rounded hover:bg-gray-50 dark:hover:bg-gray-700"
:class="n.unread ? 'bg-violet-50/60 dark:bg-violet-900/20' : ''">
<span
class="mt-2 h-2 w-2 shrink-0 rounded-full"
:class="n.unread ? 'bg-violet-600' : 'bg-transparent'"
aria-hidden="true" />
<Icon
name="solar:lock-keyhole-unlocked-bold-duotone"
class="mt-0.5 h-5 w-5 shrink-0 text-green-600" />
:name="notificationIcon(n.eventType).name"
class="mt-0.5 h-5 w-5 shrink-0"
:class="notificationIcon(n.eventType).class" />
<div class="min-w-0 flex-1">
<div class="text-sm text-gray-900 dark:text-gray-100">{{ n.message }}</div>
</div>
Expand All @@ -47,6 +48,8 @@

<script setup lang="ts">
import { DateTime } from 'luxon'
import { EventTypeEnum } from '~/purple_client'
import { documentPathBuilder } from '~/utils/url'

const api = useApi()
const { markAllRead } = useNotifications()
Expand All @@ -62,6 +65,11 @@ const { data, pending, error, refresh } = await useAsyncData(

const items = computed(() => data.value?.results ?? [])

const notificationIcon = (eventType: EventTypeEnum) =>
eventType === EventTypeEnum.RepoNotCreated
? { name: 'uil:github', class: 'text-red-600' }
: { name: 'solar:lock-keyhole-unlocked-bold-duotone', class: 'text-green-600' }

const relativeTime = (d?: Date) => (d ? (DateTime.fromJSDate(d).toRelative() ?? '') : '')

// Visiting the page clears the bell dot; the unread highlights above stay for
Expand Down
18 changes: 18 additions & 0 deletions purple/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,24 @@

# Github
GITHUB_AUTH_TOKEN = os.environ.get("PURPLE_GH_DRAFTS_READ_TOKEN")
# Repository creation on import is disabled while this is unset.
GITHUB_DRAFTS_WRITE_TOKEN = os.environ.get("PURPLE_GH_DRAFTS_WRITE_TOKEN")
# Organization in which document repositories are created, from its template
# repository GITHUB_DRAFTS_TEMPLATE_REPO.
GITHUB_DRAFTS_ORG = os.environ.get("PURPLE_GH_DRAFTS_ORG") or "rfc-editor-drafts"
GITHUB_DRAFTS_TEMPLATE_REPO = (
os.environ.get("PURPLE_GH_DRAFTS_TEMPLATE_REPO") or "base-template"
)
# GitHub populates a repository created from a template asynchronously, so its
# branch is polled for before the first commit.
GITHUB_DRAFTS_BRANCH_WAIT_ATTEMPTS = 10
GITHUB_DRAFTS_BRANCH_WAIT_SECONDS = 2

# Where a new document repository's txt and xml are downloaded from.
ID_ARCHIVE_URL = (
os.environ.get("PURPLE_ID_ARCHIVE_URL") or "https://www.ietf.org/archive/id/"
)
ID_ARCHIVE_TIMEOUT = 30 # in seconds

# API tokens
APP_API_TOKENS = {
Expand Down
25 changes: 24 additions & 1 deletion purple_api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -720,6 +720,26 @@ paths:
responses:
"204":
description: No response body
/api/rpc/documents/{draft__name}/create_repo/:
post:
operationId: documents_create_repo
description: |-
Retry creating the document's repo in rfc-editor-drafts; the outcome
arrives as a notification to the caller.
parameters:
- in: path
name: draft__name
schema:
type: string
description: Name of draft
required: true
tags:
- purple
security:
- cookieAuth: []
responses:
"202":
description: No response body
/api/rpc/documents/{draft__name}/enqueue/:
post:
operationId: documents_enqueue
Expand Down Expand Up @@ -4788,8 +4808,11 @@ components:
EventTypeEnum:
enum:
- unblocked
- repo_not_created
type: string
description: "* `unblocked` - document unblocked"
description: |-
* `unblocked` - document unblocked
* `repo_not_created` - document repo not created
FinalApproval:
type: object
description: Serialize final approval information for an RfcToBe
Expand Down
28 changes: 28 additions & 0 deletions rpc/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
datatracker_group_list_email,
datatracker_group_name,
)
from .lifecycle import draft_repo
from .lifecycle.blocked_assignments import (
apply_manual_block,
apply_manual_unblock,
Expand Down Expand Up @@ -174,6 +175,7 @@
RPC_PERSON_NAME_MAP_CACHE_KEY,
RPC_PERSON_NAME_MAP_CACHE_TTL,
compute_deep_references_task,
create_draft_repo_task,
publish_rfctobe_task,
send_mail_task,
set_stream_manager_task,
Expand Down Expand Up @@ -797,6 +799,12 @@ def import_submission(request, document_id, rpcapi: rpcapi_client.PurpleApi):
)

transaction.on_commit(lambda: set_stream_manager_task.delay(rfctobe.pk))
if draft_repo.is_enabled():
importer = request.user.rpcperson()
importer_id = importer.pk if importer else None
transaction.on_commit(
lambda: create_draft_repo_task.delay(rfctobe.pk, importer_id)
)

# create the authors
if draft_info is None:
Expand Down Expand Up @@ -1637,6 +1645,26 @@ def enqueue(self, request, draft__name=None):
)
return Response(RfcToBeSerializer(rfctobe, context={"request": request}).data)

@extend_schema(
operation_id="documents_create_repo",
request=None,
responses={202: None},
)
@action(detail=True, methods=["post"], url_path="create_repo")
def create_repo(self, request, draft__name=None):
"""Retry creating the document's repository in the background. Only a
failure is reported, as a notification to the caller."""
rfctobe = self.get_object()
if not draft_repo.is_enabled():
raise serializers.ValidationError("Creating repos is not configured.")
if rfctobe.repository:
raise serializers.ValidationError(
f"{rfctobe.name} already has a repository: {rfctobe.repository}"
)
requester = request.user.rpcperson()
create_draft_repo_task.delay(rfctobe.pk, requester.pk if requester else None)
return Response(status=status.HTTP_202_ACCEPTED)

@extend_schema(
operation_id="documents_pub_status_retrieve",
request=None,
Expand Down
159 changes: 159 additions & 0 deletions rpc/lifecycle/draft_repo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
# Copyright The IETF Trust 2026, All Rights Reserved
"""Create a document's repository in the drafts organization on import

The repository is created from the template repository GITHUB_DRAFTS_TEMPLATE_REPO
in the organization GITHUB_DRAFTS_ORG, is private, is named after the draft without
its revision, and uses the draft title as its description. A single commit on the
Approved branch replaces the README and adds the approved I-D's txt and xml from the
IETF archive (ID_ARCHIVE_URL), each together with a ".original" copy. The RPC-edits
branch is created at the same commit. Markdown sources are committed manually.

Every failure raises DraftRepoError with a message intended for the user, for
example an existing repository, a file missing from the archive, or GitHub being
unavailable. Failed attempts are not retried.
"""

import base64
import logging
import time

import requests
from django.conf import settings
from github import Github, GithubException, UnknownObjectException
from github.Auth import Token as GithubAuthToken
from github.InputGitTreeElement import InputGitTreeElement

from rpc.models import RfcToBe

logger = logging.getLogger(__name__)

BRANCH = "Approved"
EDITS_BRANCH = "RPC-edits"
COMMIT_MESSAGE = "approved I-D"
# Until GitHub has populated a repository created from a template, requests for the
# branch return 409 ("Git Repository is empty") or 404.
_NOT_READY_STATUSES = (404, 409)


class DraftRepoError(Exception):
"""Repository creation stopped; the message is shown to the user."""


class DraftRepoExists(DraftRepoError):
"""The repository already exists and was not modified."""


def is_enabled() -> bool:
return bool(settings.GITHUB_DRAFTS_WRITE_TOKEN)


def _download(filename: str) -> bytes:
url = f"{settings.ID_ARCHIVE_URL}{filename}"
try:
response = requests.get(url, timeout=settings.ID_ARCHIVE_TIMEOUT)
except requests.RequestException as err:
raise DraftRepoError(
f"Could not reach the IETF archive for {filename}"
) from err
if response.status_code == 404:
raise DraftRepoError(f"{filename} is not in the IETF archive")
if not response.ok:
raise DraftRepoError(
f"The IETF archive answered {response.status_code} for {filename}"
)
return response.content


def _repo_files(rfc_to_be: RfcToBe) -> dict[str, bytes]:
"""Files for the initial commit, as path -> content"""
name = rfc_to_be.name
if not rfc_to_be.rev:
raise DraftRepoError(f"{name} has no revision, so its files can't be found")
versioned = f"{name}-{rfc_to_be.rev}"
txt = _download(f"{versioned}.txt")
xml = _download(f"{versioned}.xml")
return {
"README.md": f"# {name}\n".encode(),
f"{versioned}.txt": txt,
f"{versioned}.original": txt,
f"{versioned}.xml": xml,
f"{versioned}.original.xml": xml,
}


def _wait_for_branch(repo):
attempts = settings.GITHUB_DRAFTS_BRANCH_WAIT_ATTEMPTS
for attempt in range(attempts):
try:
return repo.get_git_ref(f"heads/{BRANCH}")
except GithubException as err:
if err.status not in _NOT_READY_STATUSES:
raise
if attempt + 1 < attempts:
time.sleep(settings.GITHUB_DRAFTS_BRANCH_WAIT_SECONDS)
raise DraftRepoError(
f"{repo.full_name} was created, but its {BRANCH} branch never appeared, "
"so no files were committed"
)


def _commit(repo, files: dict[str, bytes]):
"""Write all files in one commit on the branch. Blobs are sent base64-encoded."""
ref = _wait_for_branch(repo)
parent = repo.get_git_commit(ref.object.sha)
tree = repo.create_git_tree(
[
InputGitTreeElement(
path,
"100644",
"blob",
sha=repo.create_git_blob(
base64.b64encode(content).decode(), "base64"
).sha,
)
for path, content in files.items()
],
parent.tree,
)
commit = repo.create_git_commit(COMMIT_MESSAGE, tree, [parent])
ref.edit(commit.sha)
repo.create_git_ref(f"refs/heads/{EDITS_BRANCH}", commit.sha)


def create_draft_repo(rfc_to_be: RfcToBe) -> str:
"""Create and populate the document's repository and return its "owner/name".

Raises DraftRepoError, with a message for the user, if this is not possible.
"""
name = rfc_to_be.name
drafts_org = settings.GITHUB_DRAFTS_ORG
try:
github = Github(auth=GithubAuthToken(settings.GITHUB_DRAFTS_WRITE_TOKEN))
org = github.get_organization(drafts_org)
try:
org.get_repo(name)
except UnknownObjectException:
pass
else:
raise DraftRepoExists(f"{drafts_org}/{name} already exists")
# Downloaded before creating, so that a missing file does not leave an empty
# repository.
files = _repo_files(rfc_to_be)
repo = org.create_repo_from_template(
name,
github.get_repo(f"{drafts_org}/{settings.GITHUB_DRAFTS_TEMPLATE_REPO}"),
description=rfc_to_be.title,
private=True,
)
_commit(repo, files)
except GithubException as err:
logger.exception("GitHub error creating the repo for %s", name)
raise DraftRepoError(
f"GitHub failed while creating {drafts_org}/{name} ({err.status})"
) from err
except requests.RequestException as err:
logger.exception("Could not reach GitHub to create the repo for %s", name)
raise DraftRepoError(f"Could not reach GitHub to create {name}") from err
rfc_to_be.repository = repo.full_name
rfc_to_be.save(update_fields=["repository"])
return repo.full_name
Loading
Loading