diff --git a/tools/issue-cfe.py b/tools/issue-cfe.py new file mode 100644 index 00000000..7734292c --- /dev/null +++ b/tools/issue-cfe.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python3 +import configparser +import email.message +import os +import sys + +from datetime import datetime, timedelta + +import xml.etree.ElementTree as etree + +from xeplib import ( + Status, load_xepinfos, choose, + make_fake_smtpconn, + interactively_extend_smtp_config, + make_smtpconn, + wraptext, +) + + +XEP_URL_PREFIX = "https://xmpp.org/extensions/" + +SUBJECT_TEMPLATE = "Call for Experience: XEP-{info[number]:04d}: {info[title]}" + +MAIL_TEMPLATE = """\ +The XEP Editor would like to Call for Experience with XEP-{info[number]:04d} \ +before presenting it to the {info[approver]} for advancing it to Final status. + + +During the Call for Experience, please answer the following questions: + +1. What software has XEP-{info[number]:04d} implemented? Please note that the \ +protocol must be implemented in at least two separate codebases (at least one \ +of which must be free or open-source software) in order to advance from Draft \ +to Final. + +2. Have developers experienced any problems with the protocol as defined in \ +XEP-{info[number]:04d}? If so, please describe the problems and, if possible, \ +suggested solutions. + +3. Is the text of XEP-{info[number]:04d} clear and unambiguous? Are more \ +examples needed? Is the conformance language (MAY/SHOULD/MUST) appropriate? \ +Have developers found the text confusing at all? Please describe any \ +suggestions you have for improving the text. + +If you have any comments about advancing XEP-{info[number]:04d} from Draft \ +to Final, please provide them by the close of business on {enddate}. After \ +the Call for Experience, this XEP might undergo revisions to address feedback \ +received, after which it will be presented to the XMPP Council for voting to \ +a status of Final. + + +You can review the specification here: + +{url} + +Please send all feedback to the standards@xmpp.org discussion list. +""" + + +def make_mail(info, enddate): + kwargs = { + "info": info, + "url": "{}xep-{:04d}.html".format( + XEP_URL_PREFIX, + info["number"], + ), + "enddate": enddate + } + + mail = email.message.EmailMessage() + mail["Subject"] = SUBJECT_TEMPLATE.format(**kwargs) + mail["XSF-XEP-Action"] = "CFE" + mail["XSF-XEP-Title"] = info["title"] + mail["XSF-XEP-Type"] = info["type"] + mail["XSF-XEP-Status"] = info["status"].value + mail["XSF-XEP-Url"] = kwargs["url"] + mail["XSF-XEP-Approver"] = info["approver"] + mail.set_content( + wraptext(MAIL_TEMPLATE.format(**kwargs)), + "plain", + "utf-8", + ) + + return mail + + +def main(): + import argparse + + parser = argparse.ArgumentParser() + + parser.add_argument( + "-c", "--config", + metavar="FILE", + type=argparse.FileType("r"), + help="Configuration file", + ) + parser.add_argument( + "-y", + dest="ask_confirmation", + default=True, + action="store_false", + help="'I trust this script to do the right thing and send emails" + "without asking for confirmation.'" + ) + parser.add_argument( + "-n", "--dry-run", + dest="dry_run", + action="store_true", + default=False, + help="Instead of sending emails, print them to stdout (implies -y)", + ) + + parser.add_argument( + "--duration", "-d", + metavar="DAYS", + default=14, + help="Duration of the CFE in days (default and at least: 14)", + type=int, + ) + + parser.add_argument( + "--xeplist", + default=None, + type=argparse.FileType("r") + ) + + parser.add_argument( + "-x", "--xep", + type=int, + dest="xeps", + action="append", + default=[], + help="XEP(s) to issue a CFE for" + ) + + parser.add_argument( + "to", + nargs="+", + help="The mail addresses to send the update mails to." + ) + + args = parser.parse_args() + + can_be_interactive = ( + os.isatty(sys.stdin.fileno()) and + os.isatty(sys.stdout.fileno()) + ) + + if not args.xeps: + print("nothing to do (use -x/--xep)", file=sys.stderr) + sys.exit(1) + + if args.duration < 14: + print("duration must be at least 14", file=sys.stderr) + sys.exit(1) + + enddate = (datetime.utcnow() + timedelta(days=args.duration)).date() + + if args.dry_run: + args.ask_confirmation = False + + if args.ask_confirmation and not can_be_interactive: + print("Cannot ask for confirmation (stdio is not a TTY), but -y is", + "not given either. Aborting.", sep="\n", file=sys.stderr) + sys.exit(2) + + config = configparser.ConfigParser() + if args.config is not None: + config.read_file(args.config) + + if args.xeplist is None: + args.xeplist = open("build/xeplist.xml", "rb") + + with args.xeplist as f: + tree = etree.parse(f) + accepted, _ = load_xepinfos(tree) + + matched_xeps = [] + has_error = False + for num in args.xeps: + try: + info = accepted[num] + except KeyError: + print("no such xep: {}".format(num), file=sys.stderr) + has_error = True + continue + + if info["status"] != Status.DRAFT: + print("XEP-{:04d} is in {}, but must be Draft".format( + num, + info["status"].value, + )) + has_error = True + continue + + matched_xeps.append(info) + + if has_error: + sys.exit(1) + + if args.dry_run: + smtpconn = make_fake_smtpconn() + else: + if can_be_interactive: + interactively_extend_smtp_config(config) + + try: + smtpconn = make_smtpconn(config) + except (configparser.NoSectionError, + configparser.NoOptionError) as exc: + print("Missing configuration: {}".format(exc), + file=sys.stderr) + print("(cannot ask for configuration on stdio because it is " + "not a TTY)", file=sys.stderr) + sys.exit(3) + + try: + for info in matched_xeps: + mail = make_mail(info, enddate) + mail["Date"] = datetime.utcnow() + mail["From"] = config.get("smtp", "from") + mail["To"] = args.to + + if args.ask_confirmation: + print() + print("---8<---") + print(mail.as_string()) + print("--->8---") + print() + choice = choose( + "Send this email? [y]es, [n]o, [a]bort: ", + "yna", + eof="a", + ) + + if choice == "n": + continue + elif choice == "a": + print("Exiting on user request.", file=sys.stderr) + sys.exit(4) + + smtpconn.send_message(mail) + finally: + smtpconn.close() + + +if __name__ == "__main__": + main() diff --git a/tools/send-updates.py b/tools/send-updates.py index bcacbf4f..680d1b9c 100755 --- a/tools/send-updates.py +++ b/tools/send-updates.py @@ -1,10 +1,8 @@ #!/usr/bin/env python3 import configparser -import getpass import itertools import email.message import os -import smtplib import sys import textwrap @@ -12,7 +10,12 @@ from datetime import datetime import xml.etree.ElementTree as etree -from xeplib import Status, Action, load_xepinfos, choose +from xeplib import ( + Status, Action, load_xepinfos, choose, + make_fake_smtpconn, + interactively_extend_smtp_config, + make_smtpconn, +) DESCRIPTION = """\ @@ -234,78 +237,6 @@ def make_nonproto_mail(action, info): return mail -def get_or_ask(config, section, name, prompt): - try: - return config.get(section, name) - except (configparser.NoSectionError, - configparser.NoOptionError): - return input(prompt) - - -def interactively_extend_smtp_config(config): - try: - host = config.get("smtp", "host") - except (configparser.NoSectionError, - configparser.NoOptionError): - host = input("SMTP server: ").strip() - port = int(input("SMTP port (blank for 587): ").strip() or "587") - user = input( - "SMTP user (leave blank for anon): " - ).strip() or None - if user: - password = getpass.getpass() - else: - password = None - else: - port = config.getint("smtp", "port", fallback=587) - user = config.get("smtp", "user", fallback=None) - password = config.get("smtp", "password", fallback=None) - - try: - from_ = config.get("smtp", "from") - except (configparser.NoSectionError, - configparser.NoOptionError): - from_ = input("From address: ").strip() - - if not config.has_section("smtp"): - config.add_section("smtp") - config.set("smtp", "host", host) - config.set("smtp", "port", str(port)) - if user: - config.set("smtp", "user", user) - if password is None: - password = getpass.getpass() - config.set("smtp", "password", password) - config.set("smtp", "from", from_) - - -def make_smtpconn(config): - host = config.get("smtp", "host") - port = config.getint("smtp", "port") - user = config.get("smtp", "user", fallback=None) - password = config.get("smtp", "password", fallback=None) - - conn = smtplib.SMTP(host, port) - conn.starttls() - if user is not None: - conn.login(user, password) - - return conn - - -def make_fake_smtpconn(): - class Fake: - def send_message(self, mail): - print("---8<---") - print(mail.as_string()) - print("--->8---") - - def close(self): - pass - - return Fake() - - def main(): import argparse diff --git a/tools/xeplib.py b/tools/xeplib.py index dc1612ac..70081150 100644 --- a/tools/xeplib.py +++ b/tools/xeplib.py @@ -1,4 +1,9 @@ +import configparser import enum +import getpass +import itertools +import smtplib +import textwrap import xml.dom.minidom @@ -169,3 +174,83 @@ def choose(prompt, options, *, continue return choice + + +def get_or_ask(config, section, name, prompt): + try: + return config.get(section, name) + except (configparser.NoSectionError, + configparser.NoOptionError): + return input(prompt) + + +def interactively_extend_smtp_config(config): + try: + host = config.get("smtp", "host") + except (configparser.NoSectionError, + configparser.NoOptionError): + host = input("SMTP server: ").strip() + port = int(input("SMTP port (blank for 587): ").strip() or "587") + user = input( + "SMTP user (leave blank for anon): " + ).strip() or None + if user: + password = getpass.getpass() + else: + password = None + else: + port = config.getint("smtp", "port", fallback=587) + user = config.get("smtp", "user", fallback=None) + password = config.get("smtp", "password", fallback=None) + + try: + from_ = config.get("smtp", "from") + except (configparser.NoSectionError, + configparser.NoOptionError): + from_ = input("From address: ").strip() + + if not config.has_section("smtp"): + config.add_section("smtp") + config.set("smtp", "host", host) + config.set("smtp", "port", str(port)) + if user: + config.set("smtp", "user", user) + if password is None: + password = getpass.getpass() + config.set("smtp", "password", password) + config.set("smtp", "from", from_) + + +def make_smtpconn(config): + host = config.get("smtp", "host") + port = config.getint("smtp", "port") + user = config.get("smtp", "user", fallback=None) + password = config.get("smtp", "password", fallback=None) + + conn = smtplib.SMTP(host, port) + conn.starttls() + if user is not None: + conn.login(user, password) + + return conn + + +def make_fake_smtpconn(): + class Fake: + def send_message(self, mail): + print("---8<---") + print(mail.as_string()) + print("--->8---") + + def close(self): + pass + + return Fake() + + +def wraptext(text): + return "\n".join( + itertools.chain( + *[textwrap.wrap(line) if line else [line] for line in text.split("\n")] + ) + )