github.com/juju/juju@v0.0.0-20240430160146-1752b71fcf00/scripts/discourse-sync/main.py (about) 1 import os 2 import sys 3 import yaml 4 from pydiscourse import DiscourseClient 5 from pydiscourse.exceptions import DiscourseClientError 6 7 8 # Get configuration from environment variables 9 DISCOURSE_HOST = os.environ.get('DISCOURSE_HOST', 'https://discourse.charmhub.io/') 10 DISCOURSE_API_USERNAME = os.environ.get('DISCOURSE_API_USERNAME') 11 DISCOURSE_API_KEY = os.environ.get('DISCOURSE_API_KEY') 12 DOCS_DIR = os.environ.get('DOCS_DIR') 13 TOPIC_IDS = os.environ.get('TOPIC_IDS') 14 15 client = DiscourseClient( 16 host=DISCOURSE_HOST, 17 api_username=DISCOURSE_API_USERNAME, 18 api_key=DISCOURSE_API_KEY, 19 ) 20 21 22 def main(): 23 if len(sys.argv) < 1: 24 sys.exit('no command provided, must be one of: check, sync, create, delete') 25 26 command = sys.argv[1] 27 if command == 'check': 28 check() 29 elif command == 'sync': 30 sync() 31 elif command == 'create': 32 create() 33 elif command == 'delete': 34 delete() 35 else: 36 exit(f'unknown command "{command}"') 37 38 39 def check(): 40 """ 41 Check all docs in the DOCS_DIR have a corresponding entry in the TOPIC_IDS 42 file, and that the corresponding topic exists on Discourse. 43 """ 44 topic_ids = get_topic_ids() 45 no_topic_id = [] 46 no_discourse_topic = [] 47 48 for entry in os.scandir(DOCS_DIR): 49 if not is_markdown_file(entry): 50 print(f'entry {entry.name}: not a Markdown file: skipping') 51 continue 52 53 doc_name = removesuffix(entry.name, ".md") 54 55 if doc_name not in topic_ids: 56 print(f'doc {doc_name}: no topic ID found') 57 no_topic_id.append(doc_name) 58 continue 59 60 topic_id = topic_ids[doc_name] 61 print(f'doc {doc_name} (topic #{topic_id}): checking topic on Discourse') 62 try: 63 client.topic( 64 slug='', 65 topic_id=topic_ids[doc_name], 66 ) 67 except DiscourseClientError: 68 print(f'doc {doc_name} (topic #{topic_id}): not found on Discourse') 69 no_discourse_topic.append(doc_name) 70 71 if no_topic_id: 72 print(f"The following docs don't have corresponding entries in {TOPIC_IDS}.") 73 print(f"Please create new Discourse topics for them, and add the new topic IDs to {TOPIC_IDS}.") 74 for doc_name in no_topic_id: 75 print(f' - {doc_name}') 76 77 if no_discourse_topic: 78 print("The following docs don't have corresponding topics on Discourse.") 79 print(f"Please create new Discourse topics for them, and update the topic IDs in f{TOPIC_IDS}.") 80 for doc_name in no_discourse_topic: 81 print(f' - {doc_name} (topic #{topic_ids[doc_name]})') 82 83 if no_topic_id or no_discourse_topic: 84 sys.exit(1) 85 86 87 def sync(): 88 """ 89 Sync all docs in the DOCS_DIR with their corresponding topics on Discourse. 90 """ 91 topic_ids = get_topic_ids() 92 couldnt_sync = {} # doc_name -> reason 93 94 for entry in os.scandir(DOCS_DIR): 95 if not is_markdown_file(entry): 96 print(f'entry {entry.name}: not a Markdown file: skipping') 97 continue 98 99 doc_name = removesuffix(entry.name, ".md") 100 content = open(entry.path, 'r').read() 101 102 if doc_name not in topic_ids: 103 couldnt_sync[doc_name] = 'no topic ID in yaml file' 104 continue 105 106 topic_id = topic_ids[doc_name] 107 print(f'doc {doc_name} (topic #{topic_id}): checking for changes') 108 try: 109 # API call to get the post ID from the topic ID 110 # TODO: we could save the post IDs in a separate yaml file and 111 # avoid this extra API call 112 topic = client.topic( 113 slug='', 114 topic_id=topic_id, 115 ) 116 except DiscourseClientError: 117 couldnt_sync[doc_name] = f'no topic with ID #{topic_id} on Discourse' 118 continue 119 120 post_id = topic['post_stream']['posts'][0]['id'] 121 # Get current contents of post 122 try: 123 post2 = client.post_by_id( 124 post_id=post_id 125 ) 126 except DiscourseClientError as e: 127 couldnt_sync[doc_name] = f"couldn't get post for topic ID #{topic_id}: {e}" 128 continue 129 130 current_contents = post2['raw'] 131 if current_contents == content.rstrip('\n'): 132 print(f'doc {doc_name} (topic #{topic_ids[doc_name]}): already up-to-date: skipping') 133 continue 134 135 # Update Discourse post 136 print(f'doc {doc_name} (topic #{topic_ids[doc_name]}): updating') 137 try: 138 client.update_post( 139 post_id=post_id, 140 content=content, 141 ) 142 except DiscourseClientError as e: 143 couldnt_sync[doc_name] = f"couldn't update post with ID #{post_id}: {e}" 144 continue 145 146 if len(couldnt_sync) > 0: 147 print("Failed to sync the following docs:") 148 for doc_name, reason in couldnt_sync.items(): 149 print(f' - {doc_name}: {reason}') 150 sys.exit(1) 151 152 153 def create(): 154 """ 155 Create new Discourse topics for each doc name provided. 156 """ 157 topic_ids = get_topic_ids() 158 docs = sys.argv[2:] 159 160 for doc_name in docs: 161 if doc_name in topic_ids: 162 print(f'skipping doc {doc_name}, it already has a topic ID') 163 continue 164 165 path = os.path.join(DOCS_DIR, doc_name+'.md') 166 try: 167 content = open(path, 'r').read() 168 except OSError as e: 169 print(f"couldn't open {path}: {e}") 170 continue 171 172 # Create new Discourse post 173 print(f'creating new post for doc {doc_name}') 174 post = client.create_post( 175 title=post_title(doc_name), 176 category_id=22, 177 content=content, 178 tags=['olm', 'autogenerated'], 179 ) 180 new_topic_id = post['topic_id'] 181 print(f'doc {doc_name}: created new topic #{new_topic_id}') 182 183 # Save topic ID in yaml map for later 184 topic_ids[doc_name] = new_topic_id 185 with open(TOPIC_IDS, 'w') as file: 186 yaml.safe_dump(topic_ids, file) 187 188 189 def delete(): 190 """ 191 Delete all Discourse topics in the TOPIC_IDS file. 192 """ 193 topic_ids = get_topic_ids() 194 195 for doc_name, topic_id in topic_ids.items(): 196 print(f'deleting doc {doc_name} (topic #{topic_id})') 197 client.delete_topic( 198 topic_id=topic_id 199 ) 200 201 # Update topic ID yaml map 202 del topic_ids[doc_name] 203 with open(TOPIC_IDS, 'w') as file: 204 yaml.safe_dump(topic_ids, file) 205 206 207 def get_topic_ids(): 208 with open(TOPIC_IDS, 'r') as file: 209 topic_ids = yaml.safe_load(file) 210 return topic_ids or {} 211 212 213 def is_markdown_file(entry: os.DirEntry) -> bool: 214 return entry.is_file() and entry.name.endswith(".md") 215 216 217 def removesuffix(text, suffix): 218 if suffix and text.endswith(suffix): 219 return text[:-len(suffix)] 220 return text 221 222 223 def post_title(doc_name: str) -> str: 224 if doc_name == 'index': 225 return 'Juju CLI commands' 226 return f"Command '{doc_name}'" 227 228 229 if __name__ == "__main__": 230 main()