diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7f35593 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +bin/ +lib/ +test/ +__pycache__/ +Dockerfile +pyvenv.cfg diff --git a/.gitignore b/.gitignore index 082ffd7..30108cf 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ lib64/ parts/ sdist/ var/ +bin/ wheels/ pip-wheel-metadata/ share/python-wheels/ @@ -128,4 +129,5 @@ dmypy.json # Pyre type checker .pyre/ -.vscode/ \ No newline at end of file +.vscode/ +pyvenv.cfg diff --git a/Dockerfile b/Dockerfile index 14c70f2..ca6bd43 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,16 @@ FROM python:3-alpine -WORKDIR /usr/src/app - -RUN apk add --no-cache git +ENV CONFLUENCE_USERNAME="" +ENV CONFLUENCE_PASSWORD="" +ENV CONFLUENCE_API_URL="" +ENV CONFLUENCE_SPACE="" +ENV CONFLUENCE_ANCESTOR_ID="" -COPY requirements.txt ./ -RUN pip install --no-cache-dir -r requirements.txt +WORKDIR /usr/src/app COPY . . +RUN apk add --no-cache git \ + && pip install --no-cache-dir -r requirements.txt + CMD [ "python", "./markdown-to-confluence.py" ] \ No newline at end of file diff --git a/confluence.py b/confluence.py index ebf09d9..1c6c877 100644 --- a/confluence.py +++ b/confluence.py @@ -7,6 +7,7 @@ API_HEADERS = { 'User-Agent': 'markdown-to-confluence', + 'X-Atlassian-Token': 'no-check' } MULTIPART_HEADERS = { @@ -32,7 +33,7 @@ def __init__(self, dry_run=False, _client=None): """Creates a new Confluence API client. - + Arguments: api_url {str} -- The URL to the Confluence API root (e.g. https://wiki.example.com/api/rest/) username {str} -- The Confluence service account username @@ -64,7 +65,7 @@ def __init__(self, def _require_kwargs(self, kwargs): """Ensures that certain kwargs have been provided - + Arguments: kwargs {dict} -- The dict of required kwargs """ @@ -115,16 +116,16 @@ def _request(self, if not response.ok: log.info('''{method} {url}: {status_code} {reason} - Params: {params} - Data: {data} - Files: {files}'''.format(method=method, - url=url, - status_code=response.status_code, - reason=response.reason, - params=params, - data=data, - files=files)) - print(response.content) + Params: {params} + Data: {data} + Files: {files}'''.format(method=method, + url=url, + status_code=response.status_code, + reason=response.reason, + params=params, + data=data, + files=files)) + return response.content # Will probably want to be more robust here, but this should work for now @@ -149,7 +150,7 @@ def exists(self, space=None, slug=None, ancestor_id=None): Specifically, this leverages a Confluence Query Language (CQL) query against the Confluence API. We assume that each slug is unique, at least to the provided space/ancestor_id. - + Arguments: space {str} -- The Confluence space to use for filtering posts slug {str} -- The page slug @@ -179,7 +180,7 @@ def create_labels(self, page_id=None, slug=None, tags=[]): We specifically require a slug to be provided, since this is how we determine if a page exists. Any other tags are optional. - + Keyword Arguments: page_id {str} -- The ID of the existing page to which the label should apply slug {str} -- The page slug to use as the label value @@ -242,7 +243,7 @@ def _create_page_payload(self, def get_attachments(self, post_id): """Gets the attachments for a particular Confluence post - + Arguments: post_id {str} -- The Confluence post ID """ @@ -251,7 +252,7 @@ def get_attachments(self, post_id): def upload_attachment(self, post_id=None, attachment_path=None): """Uploads an attachment to a Confluence post - + Keyword Arguments: post_id {str} -- The Confluence post ID attachment_path {str} -- The absolute path to the attachment @@ -276,12 +277,21 @@ def get_author(self, username): username {str} -- The Confluence username """ log.info('Looking up Confluence user key for {}'.format(username)) - response = self.get(path='user', params={'username': username}) - if not isinstance(response, dict) or not response.get('userKey'): + response = self.get(path='user', params={'accountId': username}) + if not isinstance(response, dict) or not response.get('accountId'): log.error('No Confluence user key for {}'.format(username)) return {} return response + def get_current_user(self): + """Returns the Confluence author profile for the connected username + """ + log.info('Looking up current Confluence user key') + response = self.get(path='user/current') + if not isinstance(response, dict) or not response.get('accountId'): + return {} + return response + def create(self, content=None, space=None, @@ -295,7 +305,7 @@ def create(self, If an ancestor_id is specified, then the page will be created as a child of that ancestor page. - + Keyword Arguments: content {str} -- The HTML content to upload (required) space {str} -- The Confluence space where the page should reside @@ -318,9 +328,13 @@ def create(self, ancestor_id=ancestor_id, space=space, type=type) - response = self.post(path='content/', data=page) + response = self.post(path='content/', data=page) + # print(response) page_id = response['id'] + # to resuem, we can update the labels on the page + self.create_labels(page_id=page_id, slug=slug, tags=tags) + page_url = urljoin(self.api_url, response['_links']['webui']) log.info('Page "{title}" (id {page_id}) created successfully at {url}'. @@ -353,7 +367,7 @@ def update(self, This involves updating the attachments stored on Confluence, uploading the page content, and finally updating the labels. - + Keyword Arguments: post_id {str} -- The ID of the Confluence post content {str} -- The page represented in Confluence storage format diff --git a/convert.py b/convert.py index 0e82ddb..4cd00b1 100644 --- a/convert.py +++ b/convert.py @@ -39,13 +39,14 @@ def convtoconf(markdown, front_matter={}): author_keys = front_matter.get('author_keys', []) renderer = ConfluenceRenderer(authors=author_keys) - content_html = mistune.markdown(markdown, renderer=renderer) - page_html = renderer.layout(content_html) + markdown_html = mistune.create_markdown(renderer=renderer) + # content_html = mistune.html(markdown, renderer=renderer) + page_html = markdown_html(markdown) return page_html, renderer.attachments -class ConfluenceRenderer(mistune.Renderer): +class ConfluenceRenderer(mistune.HTMLRenderer): def __init__(self, authors=[]): self.attachments = [] if authors is None: @@ -68,7 +69,7 @@ def layout(self, content): | (30% width) | (800px width) | | | | ------------------------------------------ - + Arguments: content {str} -- The HTML of the content """ @@ -91,24 +92,24 @@ def layout(self, content): main_content = column.format(width='800px', content=content) return sidebar + main_content - def header(self, text, level, raw=None): + def heading(self, text, level): """Processes a Markdown header. In our case, this just tells us that we need to render a TOC. We don't actually do any special rendering for headers. """ self.has_toc = True - return super().header(text, level, raw) + return super().heading(text, level) def render_authors(self): """Renders a header that details which author(s) published the post. This is used since Confluence will show the post published as our service account. - + Arguments: author_keys {str} -- The Confluence user keys for each post author - + Returns: str -- The HTML to prepend to the post specifying the authors """ @@ -121,7 +122,7 @@ def render_authors(self): for user_key in self.authors) return '

Authors

{}

'.format(author_content) - def block_code(self, code, lang): + def block_code(self, code, lang=None): return textwrap.dedent('''\ {l} diff --git a/markdown-to-confluence.py b/markdown-to-confluence.py index 8193f92..c8a946e 100644 --- a/markdown-to-confluence.py +++ b/markdown-to-confluence.py @@ -40,7 +40,7 @@ def get_environ_headers(prefix): def get_last_modified(repo): """Returns the paths to the last modified files in the provided Git repo - + Arguments: repo {git.Repo} -- The repository object """ @@ -53,7 +53,7 @@ def get_last_modified(repo): def get_slug(filepath, prefix=''): """Returns the slug for a given filepath - + Arguments: filepath {str} -- The filepath for the post prefix {str} -- Any prefixes to the slug @@ -134,6 +134,12 @@ def parse_args(): help= 'Print requests that would be sent- don\'t actually make requests against Confluence (note: we return empty responses, so this might impact accuracy)' ) + parser.add_argument( + '--static-path', + dest='static_path', + default='static', + help='The prefix of your static files (default: {}))'.format( + 'static')) parser.add_argument( 'posts', type=str, @@ -153,7 +159,7 @@ def parse_args(): def deploy_file(post_path, args, confluence): """Creates or updates a file in Confluence - + Arguments: post_path {str} -- The absolute path of the post to deploy to Confluence args {argparse.Arguments} -- The parsed command-line arguments @@ -185,16 +191,20 @@ def deploy_file(post_path, args, confluence): confluence_author = confluence.get_author(author) if not confluence_author: continue - front_matter['author_keys'].append(confluence_author['userKey']) + front_matter['author_keys'].append(confluence_author['accountId']) + + if len(front_matter['author_keys']) == 0: + front_matter['author_keys'].append(confluence.get_current_user()) # Normalize the content into whatever format Confluence expects html, attachments = convtoconf(markdown, front_matter=front_matter) - static_path = os.path.join(args.git, 'static') + + static_path = os.path.join(args.git, args.static_path) for i, attachment in enumerate(attachments): attachments[i] = os.path.join(static_path, attachment.lstrip('/')) - slug_prefix = '_'.join(author.lower() for author in authors) + slug_prefix = '{}{}'.format('s','_'.join(author.lower() for author in authors)) post_slug = get_slug(post_path, prefix=slug_prefix) ancestor_id = front_matter['wiki'].get('ancestor_id', args.ancestor_id) @@ -207,6 +217,7 @@ def deploy_file(post_path, args, confluence): page = confluence.exists(slug=post_slug, ancestor_id=ancestor_id, space=space) + if page: confluence.update(page['id'], content=html, @@ -219,12 +230,12 @@ def deploy_file(post_path, args, confluence): attachments=attachments) else: confluence.create(content=html, - title=front_matter['title'], - tags=tags, - slug=post_slug, - space=space, - ancestor_id=ancestor_id, - attachments=attachments) + title=front_matter['title'], + tags=tags, + slug=post_slug, + space=space, + ancestor_id=ancestor_id, + attachments=attachments) def main(): diff --git a/requirements.txt b/requirements.txt index 8eef625..f9fe0df 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ chardet==3.0.4 gitdb2==2.0.6 GitPython==3.0.4 idna==2.8 -mistune==0.8.4 +mistune==2.0.0a4 PyYAML==5.1.2 requests==2.22.0 smmap2==2.0.5 diff --git a/test/test_confluence.py b/test/test_confluence.py index a4860b3..6fe96a4 100644 --- a/test/test_confluence.py +++ b/test/test_confluence.py @@ -116,7 +116,7 @@ def testGetAuthor(self): expected = { "type": "known", "username": "foo", - "userKey": userKey, + "accountId": userKey, "profilePicture": { "path": "/download/attachments/123456/user-avatar", "width": 48, @@ -130,7 +130,7 @@ def testGetAuthor(self): is_json=True) self.api._session = client got = self.api.get_author('foo') - self.assertEqual(got['userKey'], userKey) + self.assertEqual(got['accountId'], userKey) class TestConfluenceHeaders(unittest.TestCase): diff --git a/test/test_convert.py b/test/test_convert.py index 0e8a74d..6066620 100644 --- a/test/test_convert.py +++ b/test/test_convert.py @@ -47,7 +47,7 @@ def testHeader(self): have = 'test' want = '

{}

'.format(have) renderer = ConfluenceRenderer() - got = renderer.header(have, 1) + got = renderer.heading(have, 1) got = got.strip() self.assertEqual(got, want) self.assertEqual(renderer.has_toc, True)