infocalypse
 
(Steve Dougherty)
2013-07-02: Add partial pull request support; other things.

Add partial pull request support; other things. Exit when unable to resolve WoT URI. (Possibly would be better to use ui.abort()) Add listing of WoT dependencies and pull request syntax to readme.

diff --git a/Readme.txt b/Readme.txt
--- a/Readme.txt
+++ b/Readme.txt
@@ -14,3 +14,15 @@ and many other features.
 And it works transparently: To publish, just clone to freenet:
 
     hg clone REPO freenet://USK@/REPO
+
+It supports WoT too:
+
+    hg clone REPO freenet://wot_nick@public_key_hash/repo
+
+    - Push / pull by name
+    - Pull requests
+
+Dependencies for full WoT support:
+    - PyYAML http://pyyaml.org/wiki/PyYAMLDocumentation
+    - lib-pyFreenet https://github.com/freenet/lib-pyFreenet-official
+    - DefusedXML https://pypi.python.org/pypi/defusedxml/
\ No newline at end of file
diff --git a/infocalypse/__init__.py b/infocalypse/__init__.py
--- a/infocalypse/__init__.py
+++ b/infocalypse/__init__.py
@@ -390,9 +390,11 @@ cmdtable = {
                 "[options]"),
 
     "fn-pull-request": (infocalypse_pull_request,
-                        WOT_OPTS +
-                        FCP_OPTS,
-                        "--wot id@key/repo"),
+                        [('', 'wot', '', 'WoT nick@key/repo to send request '
+                                         'to')]
+                        + WOT_OPTS
+                        + FCP_OPTS,
+                        "[--truster nick@key] --wot nick@key/repo"),
 
     "fn-push": (infocalypse_push,
                 [('', 'uri', '', 'insert URI to push to'),
@@ -693,9 +695,16 @@ def freenetclone(orig, *args, **opts):
     pulluri, pushuri = None, None
     if isfreenetpath(source):
         pulluri = freenetpathtouri(ui, source)
+
+    if not pulluri:
+        raise util.Abort()
+
     if isfreenetpath(dest):
         pushuri = freenetpathtouri(ui, dest, pull=False)
 
+    if not pushuri:
+        return
+
     # decide which infocalypse command to use.
     if pulluri and pushuri:
         action = "copy"
diff --git a/infocalypse/commands.py b/infocalypse/commands.py
--- a/infocalypse/commands.py
+++ b/infocalypse/commands.py
@@ -107,9 +107,8 @@ def infocalypse_create(ui_, repo, **opts
     params['INSERT_URI'] = insert_uri
     inserted_to = execute_create(ui_, repo, params, stored_cfg)
 
-    # TODO: Move into some function. How to separate local success context?
-    if inserted_to is not None and attributes is not None and \
-       stored_cfg.has_wot_identity(stored_cfg.get_request_uri(repo.root)):
+    if inserted_to and opts['wot']:
+        # TODO: Imports don't go out of scope, right?
         import wot
         wot.update_repo_listing(ui_, attributes['Identity'])
 
@@ -213,10 +212,15 @@ def infocalypse_pull(ui_, repo, **opts):
 
 
 def infocalypse_pull_request(ui, repo, **opts):
+    import wot
     if not opts['wot']:
-        ui.warning("Who do you want to send the pull request to? Set --wot.")
+        ui.warn("Who do you want to send the pull request to? Set --wot.\n")
         return
 
+    wot_id, repo_name = opts['wot'].split('/', 1)
+    wot.send_pull_request(ui, repo, get_truster(ui, repo, opts), wot_id,
+                          repo_name)
+
 
 def infocalypse_push(ui_, repo, **opts):
     """ Push to an Infocalypse repository in Freenet. """
diff --git a/infocalypse/wot.py b/infocalypse/wot.py
--- a/infocalypse/wot.py
+++ b/infocalypse/wot.py
@@ -7,38 +7,123 @@ import smtplib
 from base64 import b32encode
 from fcp.node import base64decode
 from keys import USK
+import yaml
+from email.mime.text import MIMEText
+import imaplib
 
+FREEMAIL_SMTP_PORT = 4025
+FREEMAIL_IMAP_PORT = 4143
+PULL_REQUEST_PREFIX = "[vcs] "
 
-def send_pull_request(ui, from_identity, to_identity):
-    local_identity = resolve_local_identity(ui, from_identity)
-    target_identity = resolve_identity(ui, from_identity, to_identity)
 
-    if local_identity is None or target_identity is None:
-        # Error.
+def send_pull_request(ui, repo, from_identifier, to_identifier, to_repo_name):
+    local_identity = resolve_local_identity(ui, from_identifier)
+    if local_identity is None:
+        return
+
+    target_identity = resolve_identity(ui, local_identity['Identity'],
+                                       to_identifier)
+    if target_identity is None:
         return
 
     from_address = to_freemail_address(local_identity)
-    to_address = to_freemail_address(to_identity)
+    to_address = to_freemail_address(target_identity)
 
     if from_address is None or to_address is None:
         if from_address is None:
-            ui.warn("{0} is not using Freemail.\n".format(from_identity[
-                    'Nickname']))
+            ui.warn("{0}@{1} is not using Freemail.\n".format(local_identity[
+                    'Nickname'], local_identity['Identity']))
         if to_address is None:
-            ui.warn("{0} is not using Freemail.\n".format(to_identity[
-                    'Nickname']))
+            ui.warn("{0}@{1} is not using Freemail.\n".format(target_identity[
+                    'Nickname'], target_identity['Identity']))
         return
 
-    # TODO: Use FCP host; default port.
-    smtp = smtplib.SMTP()
-    # TODO: Where to configure Freemail password?
-    smtp.login(from_address, )
-    smtp.sendmail()
+    # Check that a password is set.
+    cfg = Config.from_ui(ui)
+    password = cfg.get_freemail_password(local_identity['Identity'])
 
+    if password is None:
+        ui.warn("{0} does not have a Freemail password set.\n"
+                "Run hg fn-setupfreemail --truster {0}@{1}\n"
+                .format(local_identity['Nickname'], local_identity['Identity']))
+        return
+
+    to_repo = find_repo(ui, local_identity['Identity'], to_identifier,
+                        to_repo_name)
+
+    # TODO: Frequently doing {0}@{1} ... would a WoTIdentity class make sense?
+    if to_repo is None:
+        ui.warn("{0}@{1} has not published a repository named '{2}'.\n"
+                .format(target_identity['Nickname'],
+                        target_identity['Identifier'],
+                        to_repo_name))
+        return
+
+    repo_context = repo['tip']
+    # TODO: Will there always be a request URI set in the config? What about
+    # a path?
+    from_uri = cfg.get_request_uri(repo.root)
+    from_branch = repo_context.branch()
+
+    # Use double-quoted scalars so that Unicode can be included. (Nicknames.)
+    footer = yaml.dump({'request': 'pull',
+                        'vcs': 'Infocalypse',
+                        'source': from_uri + '#' + from_branch,
+                        'target': to_repo}, default_style='"',
+                       explicit_start=True, explicit_end=True,
+                       allow_unicode=True)
+
+    # TODO: Break config sanity check and sending apart so that further
+    # things can check config, prompt for whatever, then send.
+
+    source_text = ui.edit("""
+
+HG: Enter pull request message here. Lines beginning with 'HG:' are removed.
+HG: The first line has "{0}" added before it and is the email subject.
+HG: The second line should be blank.
+HG: Following lines are the body of the message.
+HG: Below is the machine-readable footer describing the request. Modifying it
+HG: or putting things below it has the potential to cause problems.
+
+{1}
+""".format(PULL_REQUEST_PREFIX, footer), from_identifier)
+    # TODO: Abort in the case of a blank message?
+    # Markdown support would be on receiving end. Maybe CLI preview eventually.
+    # (Would that even work?)
+    # TODO: Save message and load later in case sending fails.
+
+    # TODO: What if the editor uses different line endings? How to slice
+    # by-line?
+    source_lines = source_text.split('\n')
+
+    # Body is third line and after.
+    msg = MIMEText('\n'.join(source_lines[2:]))
+    msg['Subject'] = PULL_REQUEST_PREFIX + source_lines[0]
+    msg['To'] = to_address
+    msg['From'] = from_address
+
+    smtp = smtplib.SMTP(cfg.defaults['HOST'], FREEMAIL_SMTP_PORT)
+    smtp.login(from_address, password)
+    # TODO: Catch exceptions and give nice error messages.
+    smtp.sendmail(from_address, to_address, msg.as_string())
+
+
+def receive_pull_requests(ui):
+    # TODO: Terminology - send/receive different from resolve/read elsewhere.
+    # TODO: How to find YAML? Look from end backwards for "---\n" then forward
+    # from there for "...\n"?
+    # TODO: How to match local repo with the "target" URI? Based on repo list.
+
+    cfg = Config.from_ui(ui)
+    imap = imaplib.IMAP4(cfg.defaults['HOST'], FREEMAIL_IMAP_PORT)
+
+    type, message_numbers = imap.search(None, "SUBJECT", PULL_REQUEST_PREFIX)
+    print(type, message_numbers)
 
 def update_repo_listing(ui, for_identity):
     # TODO: WoT property containing edition. Used when requesting.
     config = Config.from_ui(ui)
+    # Version number to support possible format changes.
     root = ET.Element('vcs', {'version': '0'})
 
     # Add request URIs associated with the given identity.