diff --git a/apps/.gitignore b/apps/.gitignore
new file mode 100644
index 0000000..6a57d58
--- /dev/null
+++ b/apps/.gitignore
@@ -0,0 +1,2 @@
+*-build
+*.pem
diff --git a/apps/Makefile b/apps/Makefile
new file mode 100644
index 0000000..7f426f2
--- /dev/null
+++ b/apps/Makefile
@@ -0,0 +1,35 @@
+# Copyright 2021 OpenROAD Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+
+include common/config.mk
+
+APPS=pull-request-sender pull-request-sender-auth-check
+
+env:
+ virtualenv env
+ source env/bin/activate; pip install -r pull-request-sender/requirements.txt
+ source env/bin/activate; pip install -r pull-request-sender-auth-check/requirements.txt
+
+enter:
+ source env/bin/activate; bash
+
+build:
+ for A in $(APPS); do (cd $$A; make build); done
+
+deploy:
+ make build
+ (cd common; yes | gcloud --project=$(PROJECT_ID) datastore indexes create index.yaml)
+ for A in $(APPS); do (cd $$A; make deploy); done
diff --git a/apps/common/.dockerignore b/apps/common/.dockerignore
new file mode 100644
index 0000000..3e4bdd9
--- /dev/null
+++ b/apps/common/.dockerignore
@@ -0,0 +1,7 @@
+Dockerfile
+README.md
+*.pyc
+*.pyo
+*.pyd
+__pycache__
+.pytest_cache
diff --git a/apps/common/Dockerfile b/apps/common/Dockerfile
new file mode 100644
index 0000000..027c537
--- /dev/null
+++ b/apps/common/Dockerfile
@@ -0,0 +1,23 @@
+# Use the official lightweight Python image.
+# https://hub.docker.com/_/python
+FROM python:3.9-slim
+
+# Allow statements and log messages to immediately appear in the Knative logs
+ENV PYTHONUNBUFFERED True
+
+# Copy local code to the container image.
+ENV APP_HOME /app
+WORKDIR $APP_HOME
+COPY . ./
+RUN find | sort
+RUN mkdir /keys
+
+# Install production dependencies.
+RUN pip install -r requirements.txt
+
+# Run the web service on container startup. Here we use the gunicorn
+# webserver, with one worker process and 8 threads.
+# For environments with multiple CPU cores, increase the number of workers
+# to be equal to the cores available.
+# Timeout is set to 0 to disable the timeouts of the workers to allow Cloud Run to handle instance scaling.
+CMD exec gunicorn --bind :$PORT --workers 1 --threads 8 --timeout 0 app:app
diff --git a/apps/common/config.mk b/apps/common/config.mk
new file mode 100644
index 0000000..87d82bd
--- /dev/null
+++ b/apps/common/config.mk
@@ -0,0 +1,18 @@
+# Copyright 2021 OpenROAD Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+
+PROJECT_ID=openroad-robot
+REGION=us-central1
diff --git a/apps/common/db.py b/apps/common/db.py
new file mode 100755
index 0000000..293e0da
--- /dev/null
+++ b/apps/common/db.py
@@ -0,0 +1,121 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+#
+# Copyright 2021 OpenROAD Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+
+
+import datetime
+import pprint
+import requests
+import urllib
+
+
+from google.cloud import ndb
+
+
+class Token(ndb.Model):
+ login = ndb.StringProperty(indexed=True, required=True)
+ access_token = ndb.StringProperty(required=True)
+ expires_in = ndb.IntegerProperty(required=True)
+ refresh_token = ndb.StringProperty(required=True)
+ refresh_token_expires_in = ndb.IntegerProperty(required=True)
+ token_type = ndb.StringProperty(required=True)
+ updated = ndb.DateTimeProperty(auto_now=True)
+ created = ndb.DateTimeProperty(auto_now_add=True)
+
+ def to_table(self):
+ c = (datetime.datetime.utcnow() - self.created).seconds
+ u = (datetime.datetime.utcnow() - self.updated).seconds
+ ein = self.created + datetime.timedelta(seconds=self.expires_in)
+ rin = self.created + datetime.timedelta(seconds=self.refresh_token_expires_in)
+ return f"""\
+
+ | Login | {self.login} | |
+ | Created | {c}s ago | {self.created} |
+ | Last updated | {u}s ago | {self.updated} |
+ | Auth Expires In | t + {self.expires_in}s | {ein} |
+ | Refresh Expires In | t + {self.refresh_token_expires_in}s | {rin} |
+ | Type | {self.token_type} | |
+
+"""
+
+ def to_html(self):
+ return f"""\
+
+
+{self.to_table()}
+
+
+"""
+
+ @classmethod
+ def latest(cls, login):
+ with client.context():
+ q = cls.query().filter(cls.login==login).order(-cls.created)
+ for t in q:
+ return t
+
+ @classmethod
+ def delete(cls, login):
+ with client.context():
+ q = cls.query().filter(cls.login==login).order(-cls.created)
+ for t in q:
+ t.key.delete()
+
+ @classmethod
+ def from_response(cls, login, d):
+ od = dict(urllib.parse.parse_qsl(d.text))
+
+ if 'error' in od:
+ ods = pprint.pformat(od)
+ return f"""\
+
+
+ {od['error_description']}
+
+ {ods}
+
+
+"""
+ # Use the token to figure out the username of the user associated with the
+ # token.
+ headers = {
+ "Accept": "application/vnd.github.v3+json",
+ "Authorization": f"{od['token_type']} {od['access_token']}",
+ }
+ ud = requests.get(
+ url="https://api.github.com/user",
+ headers=headers,
+ ).json()
+ assert ud['login'] == login, (ud['login'], login)
+
+ od['login'] = login
+ return cls.from_json(od)
+
+ @classmethod
+ def from_json(cls, od):
+ assert 'access_token' in od, od
+ assert 'token_type' in od, od
+ assert 'expires_in' in od, od
+ od['expires_in'] = int(od['expires_in'])
+ assert 'refresh_token' in od, od
+ assert 'refresh_token_expires_in' in od, od
+ od['refresh_token_expires_in'] = int(od['refresh_token_expires_in'])
+ return cls(**od)
+
+
+client = ndb.Client()
diff --git a/apps/common/inc.mk b/apps/common/inc.mk
new file mode 100644
index 0000000..8d424b4
--- /dev/null
+++ b/apps/common/inc.mk
@@ -0,0 +1,72 @@
+# Copyright 2021 OpenROAD Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+
+MAKE_DIR := $(realpath $(dir $(lastword $(MAKEFILE_LIST))))
+
+include $(MAKE_DIR)/config.mk
+
+SRCS=\
+ ../common/Dockerfile \
+ ../common/.dockerignore \
+ ../common/db.py \
+ ../common/utils.py \
+ *.py \
+ *.txt \
+ ../../github_api
+
+PYTHONPATH=$(abspath $(PWD)/../common):$(abspath $(PWD)/../../)
+export PYTHONPATH
+
+BUILD_DIR=$(PROJECT_NAME)-build
+
+build:
+ rm -rf $(BUILD_DIR)
+ mkdir $(BUILD_DIR)
+ cp -a $(SRCS) ./$(BUILD_DIR)/
+ find $(BUILD_DIR) -name '*.pem' -delete
+ find $(BUILD_DIR) -name '*.pyc' -delete
+ find $(BUILD_DIR) -name '.*.sw*' -delete
+ find $(BUILD_DIR) -type d -empty -delete
+ find $(BUILD_DIR) | sort
+ cd $(BUILD_DIR); gcloud --project=$(PROJECT_ID) \
+ builds submit \
+ --tag gcr.io/$(PROJECT_ID)/$(PROJECT_NAME)
+
+.PHONY: build
+
+SECRETS_ENV=CLIENT_ID CLIENT_SECRET APP_ID
+SECRETS_CENV=CLIENT_TOKEN
+
+SECRETS_CMDLINE_ENV=$(shell echo $(SECRETS_ENV) | sed -e's/\([^ ]\+\) \?/\1=$(SECRETS_PREFIX)_\1:latest,/g' -e's/,$$//')
+SECRETS_CMDLINE_CENV=$(shell echo $(SECRETS_CENV) | sed -e's/\([^ ]\+\) \?/,\1=COMMON_\1:latest,/g' -e's/,$$//')
+SECRETS_CMDLINE_PEM=,/keys/app.private-key.pem=$(SECRETS_PREFIX)_CLIENT_KEY:latest
+
+deploy:
+ gcloud --project=$(PROJECT_ID) beta \
+ run deploy \
+ $(PROJECT_NAME) \
+ --image gcr.io/$(PROJECT_ID)/$(PROJECT_NAME) \
+ --platform managed --region=$(REGION) \
+ --allow-unauthenticated \
+ --set-env-vars PROJECT_ID=$(PROJECT_ID),CLIENT_KEY=/keys/app.private-key.pem \
+ --service-account=$(SERVICE_ACCOUNT)@$(PROJECT_ID).iam.gserviceaccount.com \
+ --set-secrets=$(SECRETS_CMDLINE_ENV)$(SECRETS_CMDLINE_CENV)$(SECRETS_CMDLINE_PEM)
+
+.PHONY: deploy
+
+DATASTORE_EMULATOR_HOST=localhost:8081
+db:
+ gcloud beta emulators datastore start
diff --git a/apps/common/index.yaml b/apps/common/index.yaml
new file mode 100644
index 0000000..fd9abce
--- /dev/null
+++ b/apps/common/index.yaml
@@ -0,0 +1,6 @@
+indexes:
+- kind: Token
+ properties:
+ - name: login
+ - name: created
+ direction: desc
diff --git a/apps/common/utils.py b/apps/common/utils.py
new file mode 100755
index 0000000..7a16779
--- /dev/null
+++ b/apps/common/utils.py
@@ -0,0 +1,197 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+#
+# Copyright 2021 OpenROAD Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+
+
+import os
+from collections import namedtuple
+
+import tokenlib
+
+
+_PRInfo = namedtuple("PRInfo", "user org repo pr rev")
+
+
+class PRInfo(_PRInfo):
+
+ @classmethod
+ def _tm(cls, tm_store=[]):
+ if not tm_store:
+ tm_store.append(SimpleTokenManager(
+ secret=os.environ['CLIENT_TOKEN']))
+ return tm_store[0]
+
+ @property
+ def pr_url(self):
+ """
+ >>> i = PRInfo.parse("mithro:The-OpenROAD-Project/OpenROAD/2@main")
+ >>> i.pr_url
+ 'https://github.com/The-OpenROAD-Project/OpenROAD/pull/2'
+ """
+ return f"https://github.com/{self.org}/{self.repo}/pull/{self.pr}"
+
+ @property
+ def auth_url(self):
+ return f'https://pr.gha.openroad.tools/auth?state='+self.to_token()
+
+ @property
+ def hook_url(self):
+ return f'https://a.gha.openroad.tools/hook?state='+self.to_token()
+
+ @property
+ def revoke_url(self):
+ return f'https://a.gha.openroad.tools/revoke?state='+self.to_token()
+
+ @property
+ def slug(self):
+ return f"{self.org}/{self.repo}"
+
+ @classmethod
+ def check(cls, s):
+ """
+ >>> PRInfo.check("mithro:The-OpenROAD-Project/OpenROAD/2@main")
+ True
+ >>> PRInfo.check("mithro:The-OpenROAD-Project/OpenROAD/2@")
+ False
+ """
+ try:
+ user, org, repo, pr, rev = cls.parse(s)
+ assert user, s
+ assert org, s
+ assert repo, s
+ assert pr, s
+ assert rev , s
+ return True
+ except Exception as e:
+ return False
+
+ @classmethod
+ def from_token(cls, estate):
+ assert estate is not None, estate
+ state = cls._tm().parse_token(estate)
+ return cls.parse(state)
+
+ def to_token(self, salt=None):
+ """
+ >>> i = PRInfo.parse("mithro:The-OpenROAD-Project/OpenROAD/2@main")
+ >>> t = i.to_token(b'AAA')
+ >>> t
+ 'QUFBbWl0aHJvOlRoZS1PcGVuUk9BRC1Qcm9qZWN0L09wZW5ST0FELzJAbWFpbtXQMDVIcDMABpYwq0bR16ASrpgL6HVjgv8ibtahoZzg'
+ >>> PRInfo.from_token(t)
+ PRInfo(user='mithro', org='The-OpenROAD-Project', repo='OpenROAD', pr=2, rev='main')
+ >>> t = i.to_token(b'AAB')
+ >>> t
+ 'QUFCbWl0aHJvOlRoZS1PcGVuUk9BRC1Qcm9qZWN0L09wZW5ST0FELzJAbWFpbgwv3YQAVjJL-ewHxQqxEA8aBzOSopyLwO5nW9IUnlma'
+ >>> PRInfo.from_token(t)
+ PRInfo(user='mithro', org='The-OpenROAD-Project', repo='OpenROAD', pr=2, rev='main')
+ """
+ return self._tm().make_token(str(self), salt=salt)
+
+ @classmethod
+ def from_request(cls, r):
+ estate = r.args.get('state')
+ assert estate is not None, r.args
+ return cls.from_token(estate)
+
+ @classmethod
+ def from_json(cls, j):
+ return cls(
+ j['user']['login'],
+ *j['base']['repo']['full_name'].split('/'),
+ j['number'],
+ j['head']['sha'])
+
+ @classmethod
+ def parse(cls, s):
+ """
+ >>> PRInfo.parse("mithro:The-OpenROAD-Project/OpenROAD/2@main")
+ PRInfo(user='mithro', org='The-OpenROAD-Project', repo='OpenROAD', pr=2, rev='main')
+
+ """
+ assert ':' in s, s
+ assert '/' in s, s
+ assert '@' in s, s
+ rest = s
+
+ user, rest = rest.split(':', maxsplit=1)
+ org, repo, rest = rest.split('/', maxsplit=2)
+ pr, rev = rest.split('@', maxsplit=1)
+ pr = int(pr)
+ return cls(user, org, repo, pr, rev)
+
+ def __str__(self):
+ """
+
+ >>> str(PRInfo('mithro', 'The-OpenROAD-Project', 'OpenROAD', 2, 'main'))
+ 'mithro:The-OpenROAD-Project/OpenROAD/2@main'
+
+ >>> str(PRInfo('mithro', 'The-OpenROAD-Project', 'OpenROAD', '2', 'main'))
+ 'mithro:The-OpenROAD-Project/OpenROAD/2@main'
+
+ """
+ return f"{self.user}:{self.org}/{self.repo}/{self.pr}@{self.rev}"
+
+
+class SimpleTokenManager(tokenlib.TokenManager):
+ salt_length = 3
+
+ def make_token(self, data, salt=None):
+ """Generate a new token embedding the given dict of data.
+
+ The token is a JSON dump of the given data along with an expiry
+ time and salt. It has a HMAC signature appended and is b64-encoded
+ for transmission.
+ """
+ if salt is None:
+ salt = os.urandom(self.salt_length)
+ else:
+ assert len(salt) == self.salt_length, self.salt_length
+ payload = salt+data.encode("utf8")
+ sig = self._get_signature(payload)
+ assert len(sig) == self.hashmod_digest_size
+ return tokenlib.utils.encode_token_bytes(payload + sig)
+
+ def parse_token(self, token, now=None):
+ """Extract the data embedded in the given token, if valid.
+
+ The token is valid if it has a valid signature and if the embedded
+ expiry time has not passed. If the token is not valid then this
+ method raises ValueError.
+ """
+ # Parse the payload and signature from the token.
+ try:
+ decoded_token = tokenlib.utils.decode_token_bytes(token)
+ except (TypeError, ValueError) as e:
+ raise tokenlib.errors.MalformedTokenError(str(e))
+ payload = decoded_token[:-self.hashmod_digest_size]
+ sig = decoded_token[-self.hashmod_digest_size:]
+ # Carefully check the signature.
+ # This is a deliberately slow string-compare to avoid timing attacks.
+ # Read the docstring of strings_differ for more details.
+ expected_sig = self._get_signature(payload)
+ if tokenlib.utils.strings_differ(sig, expected_sig):
+ raise tokenlib.errors.InvalidSignatureError()
+ # Only decode *after* we've confirmed the signature.
+ # This should never fail, but well, you can't be too careful.
+ return payload[self.salt_length:].decode('utf-8')
+
+
+if __name__ == "__main__":
+ os.environ['CLIENT_TOKEN'] = 'doctest'
+ import doctest
+ doctest.testmod()
diff --git a/apps/pull-request-sender-auth-check/Makefile b/apps/pull-request-sender-auth-check/Makefile
new file mode 100644
index 0000000..9eb0552
--- /dev/null
+++ b/apps/pull-request-sender-auth-check/Makefile
@@ -0,0 +1,23 @@
+# Copyright 2021 OpenROAD Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+
+
+PROJECT_NAME=pull-request-sender-auth-check
+SECRETS_PREFIX=AUTH
+SERVICE_ACCOUNT=pull-request-sender
+
+
+include ../common/inc.mk
diff --git a/apps/pull-request-sender-auth-check/app.py b/apps/pull-request-sender-auth-check/app.py
new file mode 100755
index 0000000..d0d2f70
--- /dev/null
+++ b/apps/pull-request-sender-auth-check/app.py
@@ -0,0 +1,177 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+#
+# Copyright 2021 OpenROAD Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+
+
+import datetime
+import json
+import os
+import pprint
+
+import flask
+
+import db
+import utils
+
+import github_api as gapi
+from github_api import checks
+
+
+app = flask.Flask(__name__)
+
+
+BASE_URL = "a.gha.openroad.tools"
+
+PROJECT_ID = os.environ['PROJECT_ID']
+
+CLIENT_ID = os.environ['CLIENT_ID']
+CLIENT_SECRET = os.environ['CLIENT_SECRET']
+
+# ----------------------------
+NAME = 'PR Sender Authentication'
+EXTERNAL_ID = 'pr-auth'
+
+
+def auth_check_on_private_pr(info):
+ assert isinstance(info, utils.PRInfo), info
+
+ if info.slug not in ('mithro/OpenROAD-1',):
+ return
+
+ url = f"https://api.github.com/repos/{info.slug}/commits/{info.rev}/check-runs"
+ os.environ['GITHUB_REPOSITORY'] = info.slug
+ check_runs_json = gapi.get_github_json(url)
+ assert 'check_runs' in check_runs_json, pprint.pformat(check_runs_json)
+
+ existing_checks = []
+ for c in check_runs_json['check_runs']:
+ c.pop('app')
+ if c['external_id'] != EXTERNAL_ID:
+ continue
+ if c['id'] in (2688818725,):
+ continue
+ existing_checks.append(c)
+
+ t = db.Token.latest(info.user)
+
+ now = datetime.datetime.utcnow()
+ if t is None:
+ new_check = checks.CheckRunCreate(
+ name = NAME,
+ head_sha = info.rev,
+ external_id = EXTERNAL_ID,
+
+ status = checks.CheckStatus.completed,
+ started_at = now,
+
+ conclusion = checks.CheckConclusion.action_required,
+ details_url = info.auth_url,
+ completed_at = now,
+
+ output = checks.CheckRunCreateOutput(
+ title = "Missing credentials for " +info.user,
+ summary = f"""\
+The pull request sending robot has not be authorized to send pull requests for {info.user}.
+
+[Resolve by having {info.user} click this link.]({info.auth_url})
+""",
+ text = None,
+ ),
+ actions = [],
+ )
+ else:
+ # Create the login okay status check.
+ new_check = checks.CheckRunCreate(
+ name = NAME,
+ head_sha = info.rev,
+ external_id = EXTERNAL_ID,
+
+ status = checks.CheckStatus.completed,
+ started_at = now,
+
+ conclusion = checks.CheckConclusion.success,
+ details_url = None,
+ completed_at = now,
+
+ output = checks.CheckRunCreateOutput(
+ title = "Credentials found for "+info.user,
+ summary = f"""\
+The pull request sending robot found authorization for {info.user}.
+
+[Double check by having {info.user} click this link.]({info.auth_url})
+
+([Revoke authorization]({info.revoke_url}))
+""",
+ text = None,
+ ),
+ actions = [],
+ )
+
+ uurl = f"https://api.github.com/repos/{info.slug}/check-runs"
+ if not existing_checks:
+ r = gapi.send_github_json(uurl, 'POST', new_check)
+ return 'Need to *create* this check.'+'\n'+pprint.pformat(r)+'\n'
+ else:
+ existing_id = existing_checks[0]['id']
+ purl = uurl+'/'+str(existing_id)
+ r = gapi.send_github_json(purl, 'PATCH', new_check)
+ return f'Needed to *update* this check with {existing_id}.'+'\n'+pprint.pformat(r)+'\n'
+
+# ----------------------------
+
+@app.route("/revoke", methods=['GET', 'POST'])
+def revoke():
+ info = utils.PRInfo.from_request(flask.request)
+ db.Token.delete(info.user)
+ r = auth_check_on_private_pr(info)
+ return flask.redirect(info.pr_url)
+
+# ----------------------------
+
+@app.route("/hook", methods=['GET', 'POST'])
+def hook():
+ if flask.request.method == 'POST':
+ payload = json.loads(flask.request.get_data())
+ if 'pull_request' in payload:
+ info = utils.PRInfo.from_json(payload['pull_request'])
+ r = auth_check_on_private_pr(info)
+ if r is not None:
+ return r
+ return 'Unknown hook action?\n'+json.dumps(payload, sort_keys=True, indent=2)
+ else:
+ info = utils.PRInfo.from_request(flask.request)
+ r = auth_check_on_private_pr(info)
+ return flask.redirect(info.pr_url)
+
+# ----------------------------
+
+# Redirect {BASE_URL}/localhost/ to enable OAuth authentication when
+# developing locally via redirected through the production URL.
+@app.route("/localhost/")
+def localhost(rurl):
+ qs = flask.request.query_string.decode('utf-8')
+ lurl = f"http://localhost:8080/{rurl}?{qs}"
+ return flask.redirect(lurl)
+
+
+if __name__ == "__main__":
+ import sys
+ d = json.loads(open(sys.argv[1]).read())
+ assert 'pull_request' in d, pprint.pformat(d)
+ info = utils.PRInfo.from_json(d['pull_request'])
+ print(auth_check_on_private_pr(info))
diff --git a/apps/pull-request-sender-auth-check/main.py b/apps/pull-request-sender-auth-check/main.py
new file mode 100755
index 0000000..39b0df7
--- /dev/null
+++ b/apps/pull-request-sender-auth-check/main.py
@@ -0,0 +1,28 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+#
+# Copyright 2021 OpenROAD Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+
+import os
+import sys
+sys.path.insert(0, '../')
+sys.path.insert(0, '../../')
+
+import app
+
+app.BASE_URL = f"{app.BASE_URL}/localhost"
+app.app.run(debug=True, host="0.0.0.0", port=int(os.environ.get("PORT", 8080)))
diff --git a/apps/pull-request-sender-auth-check/requirements.txt b/apps/pull-request-sender-auth-check/requirements.txt
new file mode 100644
index 0000000..36b5d85
--- /dev/null
+++ b/apps/pull-request-sender-auth-check/requirements.txt
@@ -0,0 +1,5 @@
+Flask==1.0.2
+google-cloud-ndb
+tokenlib
+gunicorn
+jwt
diff --git a/apps/pull-request-sender/Makefile b/apps/pull-request-sender/Makefile
new file mode 100644
index 0000000..92c3b21
--- /dev/null
+++ b/apps/pull-request-sender/Makefile
@@ -0,0 +1,22 @@
+# Copyright 2021 OpenROAD Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+
+
+PROJECT_NAME=pull-request-sender
+SECRETS_PREFIX=PR
+SERVICE_ACCOUNT=pull-request-sender
+
+include ../common/inc.mk
diff --git a/apps/pull-request-sender/app.py b/apps/pull-request-sender/app.py
new file mode 100644
index 0000000..15453b1
--- /dev/null
+++ b/apps/pull-request-sender/app.py
@@ -0,0 +1,223 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+#
+# Copyright 2021 OpenROAD Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+
+
+import json
+import os
+import pprint
+import requests
+import urllib
+
+import flask
+
+import db
+import utils
+
+import github_api as gapi
+from github_api import env as genv
+
+app = flask.Flask(__name__)
+
+
+BASE_URL = "pr.gha.openroad.tools"
+
+PROJECT_ID = os.environ['PROJECT_ID']
+
+CLIENT_ID = os.environ['CLIENT_ID']
+CLIENT_SECRET = os.environ['CLIENT_SECRET']
+
+# ----------------------------
+
+@app.route("/send", methods=['GET', 'POST'])
+def send():
+ data = json.loads(flask.request.get_data())
+ event_json = data['event_json']
+ private, staging, upstream, pr_sha = genv.details_from_json(data['env'])
+
+ assert private.sender is not None, (private, staging, upstream)
+
+ # Refresh the auth token
+ t = db.Token.latest(private.sender)
+ with db.client.context():
+ t = refresh_token(t)
+ if not isinstance(t, db.Token):
+ return t+'\n'
+ assert t.access_token is not None, t
+
+ pr_api_url = f'https://api.github.com/repos/{staging.slug}/commits/{pr_sha}/pulls'
+ prs_json = gapi.get_github_json(
+ pr_api_url,
+ preview="groot-preview",
+ access_token=t.access_token,
+ )
+
+ # Only include open pull requests
+ prs_json = list(filter(lambda pr: pr['state'] == 'open', prs_json))
+
+ if not prs_json:
+ # Need to create a new pull request.
+ pr_api_url = f'https://api.github.com/repos/{upstream.slug}/pulls'
+
+ create_pr_json = {
+ "base": upstream.branch,
+ "head": f"{staging.owner}:{staging.branch}",
+ "title": event_json["pull_request"]["title"],
+ "body": event_json["pull_request"]["body"],
+ "maintainer_can_modify": True,
+ "draft": True,
+ }
+ r = gapi.send_github_json(
+ pr_api_url,
+ "POST",
+ create_pr_json,
+ access_token=t.access_token,
+ )
+ prs_json.append(r)
+
+ if 'number' not in prs_json[-1]:
+ return json.dumps(prs_json)+'\n'
+ return json.dumps(prs_json[-1])+'\n'
+
+
+@app.route("/revoke", methods=['GET', 'POST'])
+def revoke():
+ user = flask.request.args.get('user')
+ db.Token.delete(user)
+ return f'Removed tokens for {user}'
+
+
+@app.route("/auth")
+def auth():
+ info = utils.PRInfo.from_request(flask.request)
+
+ # Redirect user to GitHub login
+ rjson = {
+ 'client_id': CLIENT_ID,
+ 'redirect_uri': f'https://{BASE_URL}/token',
+ 'state': info.to_token(),
+ 'login': info.user,
+ 'allow_signup': False,
+ }
+
+ d = requests.get(
+ url="https://github.com/login/oauth/authorize",
+ json=rjson,
+ allow_redirects=False,
+ )
+ return flask.redirect(d.headers['Location'])
+
+
+@app.route("/token", methods=['GET'])
+def token():
+ info = utils.PRInfo.from_request(flask.request)
+
+ code = flask.request.args.get('code')
+
+ d = requests.get(
+ url="https://github.com/login/oauth/access_token",
+ json = {
+ 'client_id': CLIENT_ID,
+ 'client_secret': CLIENT_SECRET,
+ 'code': code,
+ },
+ allow_redirects=False,
+ )
+
+ with db.client.context():
+ t = db.Token.from_response(info.user, d)
+ if not isinstance(t, db.Token):
+ return t
+
+ t.put()
+
+ # Ask the check to refresh
+ return flask.redirect(info.hook_url)
+
+
+def refresh_token(t):
+ d = requests.get(
+ url="https://github.com/login/oauth/access_token",
+ json = {
+ 'refresh_token': t.refresh_token,
+ 'grant_type': 'refresh_token',
+ 'client_id': CLIENT_ID,
+ 'client_secret': CLIENT_SECRET,
+ },
+ allow_redirects=False,
+ )
+
+ t = db.Token.from_response(t.login, d)
+ if not isinstance(t, db.Token):
+ return t
+
+ t.put()
+ return t
+
+
+@app.route("/refresh")
+def refresh():
+ user = flask.request.args.get('user')
+
+ # Get existing token
+ t = db.Token.latest(user)
+ before = t.to_table()
+
+ # Refresh the token
+ with db.client.context():
+ t = refresh_token(t)
+ after = t.to_table()
+
+ return f"""\
+
+
+ Before Refresh
+{before}
+
+
+
+ After Refresh
+{after}
+
+
+
+"""
+
+
+@app.route("/")
+def index():
+ info = utils.PRInfo.from_request(flask.request)
+
+ t = db.Token.latest(info.user)
+ return t.to_html()
+
+
+@app.route("/hook", methods=['GET', 'POST'])
+def hook():
+ data = json.loads(flask.request.get_data())
+ return json.dumps(data, sort_keys=True, indent=2)
+
+# ----------------------------
+
+# Redirect {BASE_URL}/localhost/ to enable OAuth authentication when
+# developing locally via redirected through the production URL.
+@app.route("/localhost/")
+def localhost(rurl):
+ qs = flask.request.query_string.decode('utf-8')
+ lurl = f"http://localhost:8080/{rurl}?{qs}"
+ return flask.redirect(lurl)
diff --git a/apps/pull-request-sender/main.py b/apps/pull-request-sender/main.py
new file mode 100755
index 0000000..39b0df7
--- /dev/null
+++ b/apps/pull-request-sender/main.py
@@ -0,0 +1,28 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+#
+# Copyright 2021 OpenROAD Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+
+import os
+import sys
+sys.path.insert(0, '../')
+sys.path.insert(0, '../../')
+
+import app
+
+app.BASE_URL = f"{app.BASE_URL}/localhost"
+app.app.run(debug=True, host="0.0.0.0", port=int(os.environ.get("PORT", 8080)))
diff --git a/apps/pull-request-sender/requirements.txt b/apps/pull-request-sender/requirements.txt
new file mode 100644
index 0000000..36b5d85
--- /dev/null
+++ b/apps/pull-request-sender/requirements.txt
@@ -0,0 +1,5 @@
+Flask==1.0.2
+google-cloud-ndb
+tokenlib
+gunicorn
+jwt
diff --git a/apps/upstream-sync/Makefile b/apps/upstream-sync/Makefile
new file mode 100644
index 0000000..6c2c749
--- /dev/null
+++ b/apps/upstream-sync/Makefile
@@ -0,0 +1,25 @@
+# Copyright 2021 OpenROAD Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+
+
+PROJECT_NAME=upstream-sync
+SECRETS_PREFIX=UP
+SERVICE_ACCOUNT=upstream-sync
+
+include ../common/inc.mk
+
+# Doesn't use the common secrets.
+SECRETS_CENV =
diff --git a/apps/upstream-sync/app.py b/apps/upstream-sync/app.py
new file mode 100644
index 0000000..336cee9
--- /dev/null
+++ b/apps/upstream-sync/app.py
@@ -0,0 +1,148 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+#
+# Copyright 2021 OpenROAD Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+
+import json
+import os
+import pprint
+import threading
+import traceback
+import time
+
+import flask
+
+import github_api as gapi
+
+app = flask.Flask(__name__)
+
+
+BASE_URL = "up.gha.openroad.tools"
+
+PROJECT_ID = os.environ['PROJECT_ID']
+
+CLIENT_ID = os.environ['CLIENT_ID']
+CLIENT_SECRET = os.environ['CLIENT_SECRET']
+
+# ----------------------------
+
+WORKFLOW_ID = 'github-actions-cron-sync-fork-from-upstream.yml'
+HEAD_REF = 'refs/heads/'
+
+
+class DispatchTargets:
+ _cache = None
+ _cache_updated = 0
+ _cache_lock = threading.Lock()
+
+ @classmethod
+ def get(cls):
+ now = time.time()
+
+ targets = cls._cache
+ if now - cls._cache_updated > 60*5:
+ if not cls._cache_lock.acquire(blocking=False):
+ # Is another thread is updating the cache, first try and
+ # return the old values, otherwise wait for them to be
+ # populated.
+ while True:
+ targets = cls._cache
+ if targets:
+ return targets
+ time.sleep(0.1)
+
+ try:
+ installs = gapi.get_github_json(
+ 'https://api.github.com/app/installations', jwt=True)
+
+ targets = []
+ for i in installs:
+ targets.append(i["account"]["login"])
+ targets = tuple(targets)
+
+ cls._cache = targets
+ cls._cache_updated = now
+
+ finally:
+ cls._cache_lock.release()
+
+ return targets
+
+
+def trigger_workflow_dispatch(owner, repo, branch):
+ # https://docs.github.com/en/rest/reference/actions#create-a-workflow-dispatch-event
+ # /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches
+ # workflow_id: The ID of the workflow. You can also pass the workflow file name as a string.
+ url = f'https://api.github.com/repos/{owner}/{repo}/actions/workflows/{WORKFLOW_ID}/dispatches'
+ # Payload:
+ # ref: The git reference for the workflow. The reference can be a branch or tag name.
+ # inputs: Input keys and values configured in the workflow file.
+ payload = {'ref': branch}
+ return gapi.send_github_json(url, 'POST', payload, slug=f'{owner}/{repo}')
+
+
+def trigger_workflow_dispatches(repo, branch):
+ results = {}
+ for owner in DispatchTargets.get():
+ try:
+ results[owner] = trigger_workflow_dispatch(owner, repo, branch)
+ except Exception as e:
+ results[owner] = {
+ 'message': str(e),
+ 'owner': owner,
+ 'repo': repo,
+ 'breanch': branch,
+ 'traceback': traceback.format_exc(),
+ }
+ return results
+
+
+@app.route("/hook", methods=['GET', 'POST'])
+def hook():
+ data = json.loads(flask.request.get_data())
+ try:
+ repo = data['repository']['name']
+ head = data['ref']
+ assert head.startswith(HEAD_REF), (head, HEAD_REF)
+ branch = head[len(HEAD_REF):]
+
+ results = []
+ if branch in ('master', 'main'):
+ results = trigger_workflow_dispatches(repo, branch)
+
+ return json.dumps(results, sort_keys=True, indent=2)
+
+ except Exception as e:
+ return str(e)+'\n'+json.dumps(data, sort_keys=True, indent=2)
+
+
+@app.route("/manual", methods=['GET', 'POST'])
+def manual():
+ repo = flask.request.args.get('repo')
+ if not repo:
+ return 'Missing required "repo" argument.'
+ branch = flask.request.args.get('branch')
+ if not branch:
+ return 'Missing required "branch" argument.'
+
+ results = trigger_workflow_dispatches(repo, branch)
+ return json.dumps(results, sort_keys=True, indent=2)
+
+
+@app.route("/")
+def installs():
+ return json.dumps(DispatchTargets.get())
diff --git a/apps/upstream-sync/main.py b/apps/upstream-sync/main.py
new file mode 100755
index 0000000..39b0df7
--- /dev/null
+++ b/apps/upstream-sync/main.py
@@ -0,0 +1,28 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+#
+# Copyright 2021 OpenROAD Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+
+import os
+import sys
+sys.path.insert(0, '../')
+sys.path.insert(0, '../../')
+
+import app
+
+app.BASE_URL = f"{app.BASE_URL}/localhost"
+app.app.run(debug=True, host="0.0.0.0", port=int(os.environ.get("PORT", 8080)))
diff --git a/apps/upstream-sync/requirements.txt b/apps/upstream-sync/requirements.txt
new file mode 100644
index 0000000..1d453e5
--- /dev/null
+++ b/apps/upstream-sync/requirements.txt
@@ -0,0 +1,4 @@
+Flask==1.0.2
+requests
+gunicorn
+jwt
diff --git a/github_api/__init__.py b/github_api/__init__.py
index 0258764..975cf53 100644
--- a/github_api/__init__.py
+++ b/github_api/__init__.py
@@ -24,8 +24,7 @@
import os
import requests
-
-from datetime import datetime
+import datetime as dt
def fromisoformat(s):
@@ -41,46 +40,70 @@ def fromisoformat(s):
return None
if s.endswith('Z'):
s = s[:-1]+'+00:00'
- return datetime.fromisoformat(s)
+ return dt.datetime.fromisoformat(s)
def toisoformat(s):
"""
- >>> toisoformat(datetime(2021, 5, 3, 1, 48, 37, tzinfo=timezone.utc))
+ >>> toisoformat(dt.datetime(2021, 5, 3, 1, 48, 37, tzinfo=dt.timezone.utc))
'2021-05-03T01:48:37Z'
- >>> toisoformat(datetime(2021, 5, 3, 1, 48, 37, tzinfo=None))
+ >>> toisoformat(dt.datetime(2021, 5, 3, 1, 48, 37, tzinfo=None))
'2021-05-03T01:48:37Z'
"""
if s is None:
return None
else:
- assert s.tzinfo in (timezone.utc, None), (s.tzinfo, repr(s), str(s))
- if s.tzinfo == timezone.utc:
- return datetime.isoformat(s).replace('+00:00', 'Z')
+ assert s.tzinfo in (dt.timezone.utc, None), (s.tzinfo, repr(s), str(s))
+ if s.tzinfo == dt.timezone.utc:
+ return dt.datetime.isoformat(s).replace('+00:00', 'Z')
elif s.tzinfo is None:
- return datetime.isoformat(s) + 'Z'
+ return dt.datetime.isoformat(s) + 'Z'
TOKEN_ENV_NAME = 'GITHUB_TOKEN'
-def github_headers(preview=None, _headers={}):
- if not _headers:
- # Figure out the GitHub access token.
- access_token = os.environ.get(TOKEN_ENV_NAME, None)
+_ACCESS_TOKEN_CACHE = {}
+
+
+def get_access_token(slug=None):
+ if slug not in _ACCESS_TOKEN_CACHE:
+ env_token = os.environ.get(TOKEN_ENV_NAME, None)
+
+ if slug is None:
+ if env_token is not None:
+ return env_token
+ else:
+ slug = os.environ.get('GITHUB_REPOSITORY')
+
+ assert slug is not None, slug
+
+ from . import app_token
+ access_token = app_token.get_token(slug=slug)
if not access_token:
- from . import app_token
- access_token = app_token.get_token()
- if not access_token:
- raise SystemError(
- f'Did not find an access token of `{TOKEN_ENV_NAME}`')
- _headers['Authorization'] = 'token ' + access_token
+ raise SystemError(
+ f'Did not find an access token of `{TOKEN_ENV_NAME}`')
+
+ _ACCESS_TOKEN_CACHE[slug] = access_token
+
+ assert _ACCESS_TOKEN_CACHE[slug] is not None, (slug, _ACCESS_TOKEN_CACHE)
+ return _ACCESS_TOKEN_CACHE[slug]
+
+
+def github_headers(preview=None, slug=None, access_token=None):
+ if access_token is None:
+ access_token = get_access_token(slug)
+
+ h = {}
+ if access_token:
+ h['Authorization'] = 'token ' + access_token
if preview is None:
- _headers['Accept'] = 'application/vnd.github.v3+json'
+ h['Accept'] = 'application/vnd.github.v3+json'
else:
- _headers['Accept'] = f'application/vnd.github.{preview}+json'
- return _headers
+ h['Accept'] = f'application/vnd.github.{preview}+json'
+
+ return h
def cleanup_json_dict(d):
@@ -98,11 +121,11 @@ def cleanup_json_dict(d):
del d[k]
elif isinstance(v, enum.Enum):
d[k] = v.value
- elif isinstance(v, datetime):
- d[k] = fromisoformat(v)
+ elif isinstance(v, dt.datetime):
+ d[k] = toisoformat(v)
-def send_github_json(url, mode, json_data=None, preview=None):
+def send_github_json(url, mode, json_data=None, preview=None, slug=None, access_token=None):
assert mode in ('GET', 'POST', 'PATCH', 'DELETE'), f"Unknown mode {mode}"
if dataclasses.is_dataclass(json_data):
@@ -111,7 +134,7 @@ def send_github_json(url, mode, json_data=None, preview=None):
kw = {
'url': url,
- 'headers': github_headers(preview=preview),
+ 'headers': github_headers(preview=preview, slug=slug, access_token=access_token),
}
if mode == 'POST':
f = requests.post
@@ -129,14 +152,32 @@ def send_github_json(url, mode, json_data=None, preview=None):
f = requests.delete
return f(**kw).json()
- json_data = f(**kw).json()
- return json_data
+ print(url, mode, json_data, slug)
+ r = f(**kw)
+ if r.status_code in (204,):
+ return (r.status_code, r.reason)
+ try:
+ json_data = r.json()
+ return json_data
+ except json.decoder.JSONDecodeError:
+ return (r.status_code, r.text)
def get_github_json(url, *args, **kw):
+ jwt = kw.pop('jwt', False)
preview = kw.pop('preview', None)
+ slug = kw.get('slug', None)
+ access_token = kw.get('access_token', None)
+
full_url = url.format(*args, **kw)
- return send_github_json(full_url, 'GET', preview=preview)
+ if jwt:
+ from . import app_token
+ return app_token.get_github_json_jwt(full_url)
+ return send_github_json(
+ full_url, 'GET',
+ preview=preview,
+ slug=slug,
+ access_token=access_token)
if __name__ == "__main__":
diff --git a/github_api/app_token.py b/github_api/app_token.py
index f192949..df7c364 100755
--- a/github_api/app_token.py
+++ b/github_api/app_token.py
@@ -1,4 +1,22 @@
#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+#
+# Copyright 2021 OpenROAD Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+
import json
import os
@@ -11,14 +29,20 @@
import jwt
-GH_APP_PRIVATE_KEY = pathlib.Path(__file__).parent / pathlib.Path("app.private-key.pem")
+if 'CLIENT_KEY' in os.environ:
+ GH_APP_PRIVATE_KEY = pathlib.Path(os.environ['CLIENT_KEY']).resolve()
+ assert GH_APP_PRIVATE_KEY.exists(), GH_APP_PRIVATE_KEY
+else:
+ GH_APP_PRIVATE_KEY = pathlib.Path(__file__).parent / pathlib.Path("app.private-key.pem")
def get_bearer_token():
if not GH_APP_PRIVATE_KEY.exists():
+ raise SystemError(f'Needed a bearer token but missing {GH_APP_PRIVATE_KEY}')
return None
- app_id = os.environ['GITHUB_APP_ID']
+ app_id = int(os.environ.get('APP_ID', os.environ.get('GITHUB_APP_ID', 0)))
+ assert app_id != 0
with open(GH_APP_PRIVATE_KEY, 'rb') as fh:
private_key = jwt.jwk_from_pem(fh.read())
@@ -40,23 +64,27 @@ def get_bearer_token():
return i.encode(payload, private_key, alg="RS256")
-def get_token(slug=os.environ.get('GITHUB_REPOSITORY', None)):
- assert slug is not None
+def get_github_json_jwt(url, method='GET'):
+ btoken = get_bearer_token()
+ assert btoken is not None, (url, method)
headers = {
"Accept": "application/vnd.github.v3+json",
- "Authorization": "Bearer "+get_bearer_token(),
+ "Authorization": "Bearer "+btoken,
}
- install_data = requests.get(
- url=f"https://api.github.com/repos/{slug}/installation",
- headers=headers,
- ).json()
+ f = getattr(requests, method.lower())
+ return f(url=url, headers=headers).json()
+
+
+def get_token(slug=os.environ.get('GITHUB_REPOSITORY', None)):
+ assert slug is not None
+ install_data = get_github_json_jwt(
+ f"https://api.github.com/repos/{slug}/installation")
+ assert 'id' in install_data, pprint.pformat(install_data)
install_id = install_data['id']
- access_data = requests.post(
- url=install_data['access_tokens_url'],
- headers=headers,
- ).json()
+ access_data = get_github_json_jwt(
+ install_data['access_tokens_url'], method='POST')
return access_data['token']
diff --git a/github_api/checks.py b/github_api/checks.py
new file mode 100755
index 0000000..3707276
--- /dev/null
+++ b/github_api/checks.py
@@ -0,0 +1,214 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+#
+# Copyright 2021 OpenROAD Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+
+
+import enum
+import json
+import pprint
+import dataclasses
+
+from dataclasses import dataclass, field
+from datetime import datetime, timezone
+from typing import Optional
+
+
+"""
+Small library for working with GitHub Check Runs / Suites.
+"""
+
+
+def datetime_field():
+ return field(
+# metadata=config(
+# encoder=toisoformat,
+# decoder=fromisoformat,
+# ),
+ default = None,
+ )
+
+
+# -------------------------------------------------------------------
+# Info for an existing check run.
+# -------------------------------------------------------------------
+
+
+# Annotations
+# https://api.github.com/repos/octocat/hello-world/check-runs/42/annotations
+# -------------------------------------------------------------------
+
+
+@enum.unique
+class CheckRunAnnotationLevel(enum.Enum):
+ notice = 'notice'
+ warning = 'warning'
+ failure = 'failure'
+
+
+@dataclass
+class CheckRunAnnotation:
+ path: str
+ start_line: int
+ end_line: int
+
+ annotation_level: CheckRunAnnotationLevel
+ message: str
+ title: str
+ raw_details: str
+
+ start_column: Optional[int] = None
+ end_column: Optional[int] = None
+
+
+@enum.unique
+class CheckStatus(enum.Enum):
+ queued = 'queued'
+ in_progress = 'in_progress'
+ completed = 'completed'
+
+
+# Check Runs
+# https://api.github.com/repos/octocat/hello-world/check-runs/42
+# -------------------------------------------------------------------
+
+
+@enum.unique
+class CheckConclusion(enum.Enum):
+ action_required = 'action_required'
+ cancelled = 'cancelled'
+ failure = 'failure'
+ neutral = 'neutral'
+ success = 'success'
+ skipped = 'skipped'
+ stale = 'stale'
+ timed_out = 'timed_out'
+
+
+@dataclass
+class CheckRunOutput:
+ title: Optional[str] = None
+ summary: Optional[str] = None
+ text: Optional[str] = None
+
+ annotations_count: int = 0
+ annotations_url: Optional[str] = None
+
+
+@dataclass
+class CheckSuite:
+ id: int
+
+
+@dataclass
+class CheckRun:
+
+ id: int
+ node_id: str
+
+ name: str
+ head_sha: str
+
+ details_url: str
+ external_id: str
+
+ output: CheckRunOutput
+
+ url: str
+ html_url: str
+
+ #app: dict
+ check_suite: Optional[CheckSuite] = None
+
+ status: Optional[CheckStatus] = None
+ started_at: Optional[datetime] = datetime_field()
+ completed_at: Optional[datetime] = datetime_field()
+ conclusion: Optional[CheckConclusion] = None
+
+ pull_requests: Optional[list[dict]] = None
+
+ @staticmethod
+ def _pprint(p, object, stream, indent, allowance, context, level):
+ p._pprint_dict(dataclasses.asdict(object), stream, indent, allowance, context, level)
+
+
+pprint.PrettyPrinter._dispatch[CheckRun.__repr__] = CheckRun._pprint
+
+
+# -------------------------------------------------------------------
+# Creating a check run.
+# -------------------------------------------------------------------
+
+
+@dataclass
+class CheckRunCreateAction:
+ label: str
+ description: str
+ identifier: str
+
+
+@dataclass
+class CheckRunCreateOutputImage:
+ alt: str
+ image_url: str
+ caption: str
+
+
+@dataclass
+class CheckRunCreateOutput:
+ title: str # Required
+ summary: str # Required
+ text: str
+
+ annotations: list[CheckRunAnnotation] = field(default_factory=list)
+ images: list[CheckRunCreateOutputImage] = field(default_factory=list)
+
+
+@dataclass
+class CheckRunCreate:
+ # Header: accept = "application/vnd.github.v3+json"
+ # URL: owner: str = ""
+ # URL: repo: str = ""
+ # URL: check_run_id: str = ""
+
+ name: str
+ head_sha: str
+
+ details_url: Optional[str] = None
+ external_id: Optional[str] = None
+
+ status: Optional[CheckStatus] = CheckStatus.queued
+ started_at: Optional[datetime] = datetime_field()
+
+ conclusion: Optional[CheckConclusion] = None
+ completed_at: Optional[datetime] = datetime_field()
+
+ output: Optional[CheckRunCreateOutput] = None
+
+ actions: list[CheckRunCreateAction] = field(default_factory=list)
+
+ @staticmethod
+ def _pprint(p, object, stream, indent, allowance, context, level):
+ p._pprint_dict(dataclasses.asdict(object), stream, indent, allowance, context, level)
+
+
+pprint.PrettyPrinter._dispatch[CheckRunCreate.__repr__] = CheckRunCreate._pprint
+
+
+if __name__ == "__main__":
+ import doctest
+ doctest.testmod()
diff --git a/github_api/env.py b/github_api/env.py
index 8c64475..0239f58 100755
--- a/github_api/env.py
+++ b/github_api/env.py
@@ -38,6 +38,7 @@ class Repo:
repo: str
branch: str
pr: Optional[int] = None
+ sender: Optional[str] = None
@property
def slug(self):
@@ -52,6 +53,9 @@ def pr_url(self):
assert self.pr is not None, self
return f"https://github.com/{self.slug}/pull/{self.pr}"
+ def as_dict(self):
+ return dataclasses.asdict(self)
+
def get_event_json(debug=(os.environ.get('ACTIONS_STEP_DEBUG', None)=='true')):
event_json_path = os.environ.get('GITHUB_EVENT_PATH', None)
@@ -91,6 +95,14 @@ def get_repo_default_name(key, private, _cache={}):
return _cache[key]
+def details_from_json(data):
+ private = Repo(**data['private'])
+ staging = Repo(**data['staging'])
+ upstream = Repo(**data['upstream'])
+ pr_sha = data['pr_sha']
+ return (private, staging, upstream, pr_sha)
+
+
def details(event_json=None):
# As there are three repositories involved here, things can get a bit
# confusing.
@@ -124,17 +136,20 @@ def details(event_json=None):
branch = event_json['pull_request']['head']['ref']
pr = event_json['pull_request']['number']
pr_sha = event_json['pull_request']['head']['sha']
+ sender = event_json['pull_request']['user']['login']
else:
repo_json = event_json['repository']
branch = event_json['ref']
pr = None
pr_sha = None
+ sender = event_json['sender']['login']
private_defaults = Repo(
owner = repo_json['owner']['login'],
repo = repo_json['name'],
branch = branch,
pr = pr,
+ sender = sender,
)
else:
private_defaults = Repo(
@@ -155,6 +170,7 @@ def details(event_json=None):
'PRIVATE_BRANCH',
private_defaults.branch),
pr = private_defaults.pr,
+ sender = private_defaults.sender,
)
staging = Repo(
@@ -189,9 +205,7 @@ def details(event_json=None):
print(" Staging:", staging.slug, "@", staging.branch, "(", staging.branch_url, ")")
print("Upstream:", upstream.slug, "@", upstream.branch, "(", upstream.branch_url, ")")
print()
- pr_sha_url = f"https://github.com/{private.slug}/commits/{pr_sha}"
- print()
- print(" Pull request @", pr_sha, "(", pr_sha_url, ")")
+ print(" Private Pull request @", pr_sha, "(", private.pr_url, ")", "created by", private.sender)
print()
return (private, staging, upstream, pr_sha)
diff --git a/send_pr/action.py b/send_pr/action.py
index 64b5522..fc31f93 100755
--- a/send_pr/action.py
+++ b/send_pr/action.py
@@ -23,7 +23,9 @@
import os
import pathlib
import pprint
+import requests
import sys
+import urllib
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
@@ -35,51 +37,37 @@
def send_pr():
- github_api.TOKEN_ENV_NAME = 'STAGING_GITHUB_TOKEN'
event_json = genv.get_event_json()
private, staging, upstream, pr_sha = genv.details(event_json)
-
- # Figure out if there are any pull requests associated with the sha at the
- # moment.
- pr_api_url = f'https://api.github.com/repos/{staging.slug}/commits/{pr_sha}/pulls'
- prs_json = get_github_json(pr_api_url, preview="groot-preview")
+ assert private.sender is not None, (private, staging, upstream)
+
+ data = {
+ 'event_json': event_json,
+ 'env': {
+ 'private': private.as_dict(),
+ 'staging': staging.as_dict(),
+ 'upstream': upstream.as_dict(),
+ 'pr_sha': pr_sha,
+ },
+ }
print()
- print(f"::group::Current pull requests from {staging.slug} for {pr_sha}")
- pprint.pprint(prs_json)
+ print("::group::Sending JSON Data")
+ print(json.dumps(data))
print("::endgroup::")
print()
- if not prs_json:
- # Need to create a new pull request.
- pr_api_url = f'https://api.github.com/repos/{upstream.slug}/pulls'
-
- create_pr_json = {
- "base": upstream.branch,
- "head": f"{staging.owner}:{staging.branch}",
- "title": event_json["pull_request"]["title"],
- "body": event_json["pull_request"]["body"],
- "maintainer_can_modify": True,
- "draft": True,
- }
- r = send_github_json(pr_api_url, "POST", create_pr_json)
- print(f"::group::Created pull request from {staging.slug} {staging.branch} to {upstream.slug}")
- pprint.pprint(r)
- print("::endgroup::")
- print()
- prs_json.append(r)
- else:
- print()
- print("Pull request already existed!")
- print()
-
- upstream.pr = prs_json[-1]["number"]
+ pr_json = requests.post(
+ url='https://pr.gha.openroad.tools/send',
+ json=data,
+ ).json()
print()
- print(" Private PR:", private.pr, private.pr_url)
- print("Upstream PR:", upstream.pr, upstream.pr_url)
-
- print("::set-output name=pr::"+str(upstream.pr))
+ print("::group::Response JSON Data")
+ print(json.dumps(pr_json))
+ print("::endgroup::")
+ print()
+ print("::set-output name=pr::"+str(pr_json['number']))
return