From 0a77a507c69f7a68dfc5004375cd02f0f85cedd5 Mon Sep 17 00:00:00 2001 From: Andrey Marakulin Date: Thu, 6 Jul 2023 23:52:49 +0300 Subject: [PATCH] Changes from https://github.com/profcomff/issue-github-tgbot --- .github/workflows/build_and_publish.yml | 77 +++++++++++++++++++++++++ README.md | 60 ++++++++++++++++++- src/github_api.py | 43 +++++++------- src/graphql/get_repos.graphql | 3 + src/graphql/issue_actions.graphql | 34 +++++++++++ src/handlers.py | 35 +++++------ src/settings.py | 10 ++++ 7 files changed, 217 insertions(+), 45 deletions(-) create mode 100644 .github/workflows/build_and_publish.yml diff --git a/.github/workflows/build_and_publish.yml b/.github/workflows/build_and_publish.yml new file mode 100644 index 0000000..06f60ac --- /dev/null +++ b/.github/workflows/build_and_publish.yml @@ -0,0 +1,77 @@ +name: Build, publish and deploy docker + +on: + push: + branches: [ 'main' ] + tags: + - 'v*' + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push-image: + name: Build and push + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout repository + uses: actions/checkout@master + + - name: Log in to the Container registry + uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=tag,enable=${{ startsWith(github.ref, 'refs/tags/v') }} + type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} + type=raw,value=test,enable=true + - name: Build and push Docker image + uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + deploy-production: + name: Deploy Production + needs: build-and-push-image + if: startsWith(github.ref, 'refs/tags/v') + runs-on: [ self-hosted, Linux ] + environment: + name: Production + env: + CONTAINER_NAME: ${{ vars.DOCKER_CONTAINER_NAME }} + permissions: + packages: read + steps: + - name: Pull new version + run: docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + - name: Run new version + run: | + docker stop ${{ env.CONTAINER_NAME }} || true && docker rm ${{ env.CONTAINER_NAME }} || true + docker run \ + --detach \ + --restart always \ + --env BOT_TOKEN='${{ secrets.BOT_TOKEN }}' \ + --env BOT_NICKNAME='${{ secrets.BOT_NICKNAME }}' \ + --env GH_ACCOUNT_TOKEN='${{ secrets.GH_ACCOUNT_TOKEN }}' \ + --env GH_ORGANIZATION_NICKNAME='${{ vars.GH_ORGANIZATION_NICKNAME }}' \ + --env GH_SCRUM_STATE='${{ vars.GH_SCRUM_STATE }}' \ + --env GH_SCRUM_ID='${{ vars.GH_SCRUM_ID }}' \ + --env GH_SCRUM_FIELD_ID='${{ vars.GH_SCRUM_FIELD_ID }}' \ + --env GH_SCRUM_FIELD_DEFAULT_STATE='${{ vars.GH_SCRUM_FIELD_DEFAULT_STATE }}' \ + --name ${{ env.CONTAINER_NAME }} \ + ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest diff --git a/README.md b/README.md index 4c1263c..ce4b0bb 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ ## Bot for creation GitHub issue from Telegram chat -Only for GitHub organizations (not for personal accounts) +Only for GitHub organizations repos issues (not for personal repos issues) #### Setup @@ -11,7 +11,11 @@ Only for GitHub organizations (not for personal accounts) * `GH_ACCOUNT_TOKEN` - From step 2 4. and environment variables * `GH_ORGANIZATION_NICKNAME` - Organization nickname for manage issue - * `DOCKER_CONTAINER_NAME` - Name docker for container + * `DOCKER_CONTAINER_NAME` - Name of docker container + * `GH_SCRUM_STATE` - Set to 0 if you doesn't want to automatically add new issue to scrum board, else read Scrum setup + * `GH_SCRUM_ID` - Value doesn't matter if GH_SCRUM_STATE=0 + * `GH_SCRUM_FIELD_ID` - Value doesn't matter if GH_SCRUM_STATE=0 + * `GH_SCRUM_FIELD_DEFAULT_STATE` - Value doesn't matter if GH_SCRUM_STATE=0 5. Run Docker Example: ```commandline @@ -22,3 +26,55 @@ Only for GitHub organizations (not for personal accounts) #### Example ![image](https://user-images.githubusercontent.com/51162917/225610117-0a5689ec-1742-4c11-8938-de8d098b5092.png) + +#### Scrum setup +If you want to automatically add new issue to scrum board set `GH_SCRUM_STATE=1` +and set: +* `GH_SCRUM_ID` - this is identifier of scrum board +* `GH_SCRUM_FIELD_ID` - this is identifier of field in scrum board (e.g. columns) +* `GH_SCRUM_FIELD_DEFAULT_STATE` - this is default field(column) state (e.g. backlog) + +You can find this id's with only with GraphQL requests ([use Explorer](https://docs.github.com/ru/graphql/overview/explorer)): + +```graphql +# This query return scrum project's id's (GH_SCRUM_ID). Bot only can add issue only to one project +{organization(login: "GH_ORGANIZATION_NICKNAME") { + projectsV2(first: 100) { + edges { + node { + title + id + public +}}}}} + +# Use GH_SCRUM_ID for next request. +# Usually, if you want to set column automatically, you need results from ProjectV2SingleSelectField +# GH_SCRUM_FIELD_ID is id +# GH_SCRUM_FIELD_DEFAULT_STATE is options id + +{node(id: "GH_SCRUM_ID") { + ... on ProjectV2 { + fields(first: 20) { + nodes { + ... on ProjectV2Field { + id + name + } + ... on ProjectV2IterationField { + id + name + configuration { + iterations { + startDate + id + } + } + } + ... on ProjectV2SingleSelectField { + id + name + options { + id + name +}}}}}}} +``` diff --git a/src/github_api.py b/src/github_api.py index 4beef99..289d2e7 100644 --- a/src/github_api.py +++ b/src/github_api.py @@ -7,20 +7,22 @@ from gql.transport.requests import RequestsHTTPTransport from gql.transport.requests import log as requests_logger requests_logger.setLevel(logging.WARNING) +logging.getLogger("httpx").propagate = False class Github: - def __init__(self, organization_nickname, token): - self.organization_nickname = organization_nickname - self.issue_url = 'https://api.github.com/repos/' + organization_nickname + '/{}/issues' - self.org_repos_url = f'https://api.github.com/orgs/{organization_nickname}/repos' - self.org_members_url = f'https://api.github.com/orgs/{organization_nickname}/members' + def __init__(self, settings): + self.to_scrum = True + self.settings = settings + self.organization_nickname = settings.GH_ORGANIZATION_NICKNAME + self.issue_url = 'https://api.github.com/repos/' + settings.GH_ORGANIZATION_NICKNAME + '/{}/issues' + self.org_members_url = f'https://api.github.com/orgs/{settings.GH_ORGANIZATION_NICKNAME}/members' self.session = requests.Session() self.headers = { 'Accept': 'application/vnd.github+json', - 'Authorization': f'Bearer {token}', + 'Authorization': f'Bearer {settings.GH_ACCOUNT_TOKEN}', 'X-GitHub-Api-Version': '2022-11-28', 'Content-Type': 'application/x-www-form-urlencoded' } @@ -43,27 +45,22 @@ def __read_queries(self): self.q_get_repos = gql(f.read()) with open('src/graphql/add_to_scrum.graphql') as f: self.q_add_to_scrum = gql(f.read()) + with open('src/graphql/issue_actions.graphql') as f: + self.q_issue_actions = gql(f.read()) - def open_issue(self, repo, title, comment): - payload = {'title': title, 'body': comment, 'projects': f'{self.organization_nickname}/7'} - r = self.session.post(self.issue_url.format(repo), headers=self.headers, json=payload) - if 'Issues are disabled for this repo' in r.text: - raise GithubIssueDisabledError + def open_issue(self, repo_id, title, body): + params = {'repositoryId': repo_id, 'title': title, 'body': body} + r = self.client.execute(self.q_issue_actions, operation_name='CreateIssue', variable_values=params) return r - def old_get_repos(self, page=1): - data = {'sort': 'pushed', 'per_page': 9, 'page': page} - r = self.session.get(self.org_repos_url, headers=self.headers, params=data) - return r.json() - def get_repos(self, page_info): params = {'gh_query': f'org:{self.organization_nickname} archived:false fork:true is:public sort:updated'} - if page_info == 'repos_start': + if page_info == 'repos_start': # start page r = self.client.execute(self.q_get_repos, operation_name='getReposInit', variable_values=params) - elif page_info.startswith('repos_after'): + elif page_info.startswith('repos_after'): # next page params['cursor'] = page_info.split('_')[2] r = self.client.execute(self.q_get_repos, operation_name='getReposAfter', variable_values=params) - else: # repos_before + else: # previous page params['cursor'] = page_info.split('_')[2] r = self.client.execute(self.q_get_repos, operation_name='getReposBefore', variable_values=params) return r['repos'] @@ -98,17 +95,17 @@ def set_assignee(self, issue_url, member_login, comment): def add_to_scrum(self, node_id): try: - params = {'projectId': 'PVT_kwDOBaPiZM4AFiz-', + params = {'projectId': self.settings.GH_SCRUM_ID, 'contentId': node_id} r = self.client.execute(self.q_add_to_scrum, operation_name='addToScrum', variable_values=params) item_id = r['addProjectV2ItemById']['item']['id'] logging.info(f'Node {node_id} successfully added to scrum with contentId= {item_id}') - params = {'projectId': 'PVT_kwDOBaPiZM4AFiz-', + params = {'projectId': self.settings.GH_SCRUM_ID, 'itemId': item_id, - 'fieldId': 'PVTSSF_lADOBaPiZM4AFiz-zgDMeOc', - 'value': '4a4a1bb5'} # BACKLOG_OPTION_ID + 'fieldId': self.settings.GH_SCRUM_FIELD_ID, + 'value': self.settings.GH_SCRUM_FIELD_DEFAULT_STATE} # backlog column r = self.client.execute(self.q_add_to_scrum, operation_name='setScrumStatus', variable_values=params) if 'errors' in r: logging.warning(f'''itemId={item_id} not set status. Reason: {r['errors']}''') diff --git a/src/graphql/get_repos.graphql b/src/graphql/get_repos.graphql index ef1d3da..3510d3c 100644 --- a/src/graphql/get_repos.graphql +++ b/src/graphql/get_repos.graphql @@ -12,6 +12,7 @@ query getReposInit($gh_query: String!) { ... on Repository { name url + id } } } @@ -32,6 +33,7 @@ query getReposAfter($gh_query: String!, $cursor: String!) { ... on Repository { name url + id } } } @@ -52,6 +54,7 @@ query getReposBefore($gh_query: String!, $cursor: String!) { ... on Repository { name url + id } } } diff --git a/src/graphql/issue_actions.graphql b/src/graphql/issue_actions.graphql index e69de29..de235d9 100644 --- a/src/graphql/issue_actions.graphql +++ b/src/graphql/issue_actions.graphql @@ -0,0 +1,34 @@ +mutation CreateIssue ($repositoryId: ID!, $title: String!, $body: String!) { + createIssue( + input: { + repositoryId: $repositoryId, + title: $title, + body: $body + } + ) { + issue { + number + body + url + id + } + } +} + +mutation CreateIssueScrum ($repositoryId: ID!, $title: String!, $body: String!, $projectIds: [ID!]) { + createIssue( + input: { + repositoryId: $repositoryId, + title: $title, + body: $body, + projectIds: $projectIds + } + ) { + issue { + number + body + url + id + } + } +} \ No newline at end of file diff --git a/src/handlers.py b/src/handlers.py index 717e44d..6bf5e66 100644 --- a/src/handlers.py +++ b/src/handlers.py @@ -9,13 +9,15 @@ from telegram.ext import ContextTypes, CallbackContext from telegram.constants import ParseMode +from gql.transport.exceptions import TransportError, TransportQueryError + from src.settings import Settings from src.issue_message import TgIssueMessage from src.github_api import Github, GithubIssueDisabledError from src.answers import ans settings = Settings() -github = Github(settings.GH_ORGANIZATION_NICKNAME, settings.GH_ACCOUNT_TOKEN) +github = Github(settings) async def native_error_handler(update, context): @@ -158,7 +160,7 @@ def __keyboard_repos(page_info): buttons = [] for repo in repos_info['edges']: - buttons.append([InlineKeyboardButton(repo['node']['name'], callback_data='repo_' + repo['node']['name'])]) + buttons.append([InlineKeyboardButton(repo['node']['name'], callback_data='repo_' + repo['node']['id'])]) buttons.append([]) if repos_info['pageInfo']['hasPreviousPage']: @@ -193,35 +195,28 @@ def __keyboard_assign(page): async def __create_issue(update: Update, context: ContextTypes.DEFAULT_TYPE): - repo_name = str(update.callback_query.data.split('_')[1]) + repo_id = str(update.callback_query.data.split('_', 1)[1]) imessage = TgIssueMessage(update.callback_query.message.text_html) link_to_msg = __get_link_to_telegram_message(update) github_comment = imessage.comment + ans['issue_open'].format(update.callback_query.from_user.full_name, link_to_msg) try: - r = github.open_issue(repo_name, imessage.issue_title, github_comment) - except GithubIssueDisabledError: - await context.bot.answer_callback_query(update.callback_query.id, 'У этого репозитория отключены Issue.') - logging.warning(f'{str_sender_info(update)} Try to open issue, but issue for {repo_name} disabled') - keyboard = InlineKeyboardMarkup([[InlineKeyboardButton('⚠️ Select repo to create', - callback_data='repos_start')]]) - return keyboard, imessage.get_text() - - if r.status_code == 201: - response = r.json() - imessage.set_issue_url(response['html_url']) + r = github.open_issue(repo_id, imessage.issue_title, github_comment) + imessage.set_issue_url(r['createIssue']['issue']['url']) keyboard = InlineKeyboardMarkup([[InlineKeyboardButton('↩️', callback_data='quite'), InlineKeyboardButton('👤', callback_data='assign_1'), InlineKeyboardButton('❌', callback_data='close')]]) - logging.info(f'{str_sender_info(update)} Succeeded open Issue: {response["html_url"]}') - threading.Thread(target=github.add_to_scrum, args=(response['node_id'], )).start() + logging.info(f'''{str_sender_info(update)} Succeeded open Issue: {r['createIssue']['issue']['url']}''') + if settings.GH_SCRUM_STATE: + threading.Thread(target=github.add_to_scrum, args=(r['createIssue']['issue']['id'], )).start() - else: - await context.bot.answer_callback_query(update.callback_query.id, f'Response code: {r.status_code}') - keyboard = InlineKeyboardMarkup([[InlineKeyboardButton('Настроить', callback_data='setup')]]) - logging.error(f'{str_sender_info(update)} Failed to open Issue [{r.status_code}] {r.text}') + except TransportQueryError as err: + await context.bot.answer_callback_query(update.callback_query.id, f'''{err.errors[0]['message']}''') + keyboard = InlineKeyboardMarkup([[InlineKeyboardButton('⚠️ Select repo to create', + callback_data='repos_start')]]) + logging.error(f'{str_sender_info(update)} Failed to open Issue: {err.args}') return keyboard, imessage.get_text() diff --git a/src/settings.py b/src/settings.py index 0f91087..585fd81 100644 --- a/src/settings.py +++ b/src/settings.py @@ -8,6 +8,16 @@ class Settings(BaseSettings): GH_ACCOUNT_TOKEN: str GH_ORGANIZATION_NICKNAME: str + # This id's used for automatically add issue to scrum board + # and set default column such a backlog + # If you want to use scrum bard just set GH_SCRUM_STATE to True + # and pass project id, field if, and default field state id + GH_SCRUM_STATE: bool = False + GH_SCRUM_ID: str = 'PVT_kwDOBaPiZM4AFiz-' + GH_SCRUM_FIELD_ID: str = 'PVTSSF_lADOBaPiZM4AFiz-zgDMeOc' + GH_SCRUM_FIELD_DEFAULT_STATE: str = '4a4a1bb5' + + class Config: """Pydantic BaseSettings config""" case_sensitive = True