All of lore.kernel.org
 help / color / mirror / Atom feed
From: Alexander Kanavin <alex.kanavin@gmail.com>
To: Stefan Herbrechtsmeier <stefan.herbrechtsmeier-oss@weidmueller.com>
Cc: OE-core <openembedded-core@lists.openembedded.org>,
	Lukas Funke <lukas.funke@weidmueller.com>,
	Stefan Herbrechtsmeier <stefan.herbrechtsmeier@weidmueller.com>
Subject: Re: [OE-core] [PATCH 4/5] recipetool: add go recipe generator
Date: Fri, 6 May 2022 09:15:28 +0200	[thread overview]
Message-ID: <CANNYZj-Ve7+CDyrfdNP8rQbYO-JY+yo=n5E8A3EcHLjAyVSHFQ@mail.gmail.com> (raw)
In-Reply-To: <20220506065917.1375-4-stefan.herbrechtsmeier-oss@weidmueller.com>

This is a lot of code. Can you add some documentation for it, what it
does and how it works? If someone would want to understand it, how
would they go about it?

Alex

On Fri, 6 May 2022 at 09:00, Stefan Herbrechtsmeier
<stefan.herbrechtsmeier-oss@weidmueller.com> wrote:
>
> From: Lukas Funke <lukas.funke@weidmueller.com>
>
> Signed-off-by: Lukas Funke <lukas.funke@weidmueller.com>
> Signed-off-by: Stefan Herbrechtsmeier <stefan.herbrechtsmeier@weidmueller.com>
> ---
>
>  scripts/lib/recipetool/create_go.py | 394 ++++++++++++++++++++++++++++
>  1 file changed, 394 insertions(+)
>  create mode 100644 scripts/lib/recipetool/create_go.py
>
> diff --git a/scripts/lib/recipetool/create_go.py b/scripts/lib/recipetool/create_go.py
> new file mode 100644
> index 0000000000..4552e9b470
> --- /dev/null
> +++ b/scripts/lib/recipetool/create_go.py
> @@ -0,0 +1,394 @@
> +# Recipe creation tool - go support plugin
> +#
> +# Copyright (C) 2022 Weidmueller GmbH & Co KG
> +# Author: Lukas Funke <lukas.funke@weidmueller.com>
> +#
> +# Copyright (c) 2009 The Go Authors. All rights reserved.
> +#
> +#  SPDX-License-Identifier: BSD-3-Clause AND GPL-2.0-only
> +#
> +import bb.utils
> +from collections import namedtuple
> +from enum import Enum
> +from html.parser import HTMLParser
> +import json
> +import logging
> +import os
> +import re
> +import subprocess
> +import sys
> +import tempfile
> +import shutil
> +from urllib.error import URLError, HTTPError
> +import urllib.parse
> +import urllib.request
> +
> +from recipetool.create import RecipeHandler, handle_license_vars, ensure_native_cmd
> +
> +GoImport = namedtuple('GoImport', 'reporoot vcs repourl suffix')
> +logger = logging.getLogger('recipetool')
> +
> +tinfoil = None
> +
> +re_pseudo_semver = re.compile(r"v([0-9]+)\.([0-9]+).([0-9]+|\([0-9]+\+1\))-(pre\.[0-9]+\.)?([0-9]+\.)?(?P<utc>[0-9]+)-(?P<sha1_abbrev>[0-9Aa-zA-Z]+)")
> +re_semver = re.compile(r"^v(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)(?:-(?P<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$")
> +
> +def tinfoil_init(instance):
> +    global tinfoil
> +    tinfoil = instance
> +
> +class GoRecipeHandler(RecipeHandler):
> +
> +    def _resolve_repository_static(self, modulepath):
> +        _rootpath = None
> +        _vcs = None
> +        _repourl = None
> +        _suffix = None
> +
> +        host, _, path = modulepath.partition('/')
> +
> +        class vcs(Enum):
> +            pathprefix = "pathprefix"
> +            regexp = "regexp"
> +            vcs = "vcs"
> +            repo = "repo"
> +            check = "check"
> +            schemelessRepo = "schemelessRepo"
> +
> +        # GitHub
> +        vcsGitHub = {}
> +        vcsGitHub[vcs.pathprefix] = "github.com"
> +        vcsGitHub[vcs.regexp] = re.compile(r'^(?P<root>github\.com/[A-Za-z0-9_.\-]+/[A-Za-z0-9_.\-]+)(/(?P<suffix>[A-Za-z0-9_.\-]+))*$')
> +        vcsGitHub[vcs.vcs] = "git"
> +        vcsGitHub[vcs.repo] = "https://\g<root>"
> +
> +        # Bitbucket
> +        vcsBitbucket = {}
> +        vcsBitbucket[vcs.pathprefix] = "bitbucket.org"
> +        vcsBitbucket[vcs.regexp] = re.compile(r'^(?P<root>bitbucket\.org/(?P<bitname>[A-Za-z0-9_.\-]+/[A-Za-z0-9_.\-]+))(/(?P<suffix>[A-Za-z0-9_.\-]+))*$')
> +        vcsBitbucket[vcs.vcs] = "git"
> +        vcsBitbucket[vcs.repo] = "https://\g<root>"
> +
> +        # IBM DevOps Services (JazzHub)
> +        vcsIBMDevOps = {}
> +        vcsIBMDevOps[vcs.pathprefix] = "hub.jazz.net/git"
> +        vcsIBMDevOps[vcs.regexp] = re.compile(r'^(?P<root>hub\.jazz\.net/git/[a-z0-9]+/[A-Za-z0-9_.\-]+)(/(?P<suffix>[A-Za-z0-9_.\-]+))*$')
> +        vcsIBMDevOps[vcs.vcs] = "git"
> +        vcsIBMDevOps[vcs.repo] = "https://\g<root>"
> +
> +        # Git at Apache
> +        vcsApacheGit = {}
> +        vcsApacheGit[vcs.pathprefix] = "git.apache.org"
> +        vcsApacheGit[vcs.regexp] = re.compile(r'^(?P<root>git\.apache\.org/[a-z0-9_.\-]+\.git)(/(?P<suffix>[A-Za-z0-9_.\-]+))*$')
> +        vcsApacheGit[vcs.vcs] = "git"
> +        vcsApacheGit[vcs.repo] = "https://\g<root>"
> +
> +        # Git at OpenStack
> +        vcsOpenStackGit = {}
> +        vcsOpenStackGit[vcs.pathprefix] = "git.openstack.org"
> +        vcsOpenStackGit[vcs.regexp] = re.compile(r'^(?P<root>git\.openstack\.org/[A-Za-z0-9_.\-]+/[A-Za-z0-9_.\-]+)(\.git)?(/(?P<suffix>[A-Za-z0-9_.\-]+))*$')
> +        vcsOpenStackGit[vcs.vcs] = "git"
> +        vcsOpenStackGit[vcs.repo] = "https://\g<root>"
> +
> +        # chiselapp.com for fossil
> +        vcsChiselapp = {}
> +        vcsChiselapp[vcs.pathprefix] = "chiselapp.com"
> +        vcsChiselapp[vcs.regexp] = re.compile(r'^(?P<root>chiselapp\.com/user/[A-Za-z0-9]+/repository/[A-Za-z0-9_.\-]+)$')
> +        vcsChiselapp[vcs.vcs] = "fossil"
> +        vcsChiselapp[vcs.repo] = "https://\g<root>"
> +
> +        # General syntax for any server.
> +        # Must be last.
> +        vcsGeneralServer = {}
> +        vcsGeneralServer[vcs.regexp] = re.compile("(?P<root>(?P<repo>([a-z0-9.\-]+\.)+[a-z0-9.\-]+(:[0-9]+)?(/~?[A-Za-z0-9_.\-]+)+?)\.(?P<vcs>bzr|fossil|git|hg|svn))(/~?(?P<suffix>[A-Za-z0-9_.\-]+))*$")
> +        vcsGeneralServer[vcs.schemelessRepo] = True
> +
> +        vcsPaths = [vcsGitHub, vcsBitbucket, vcsIBMDevOps, vcsApacheGit, vcsOpenStackGit, vcsChiselapp, vcsGeneralServer]
> +
> +        if modulepath.startswith("example.net") or modulepath == "rsc.io":
> +            logger.warning("Suspicious module path %s" % modulepath)
> +            return None
> +        if modulepath.startswith("http:") or modulepath.startswith("https:"):
> +            logger.warning("Import path should not start with %s %s" % ("http", "https"))
> +            return None
> +
> +        for srv in vcsPaths:
> +            m = srv[vcs.regexp].match(modulepath)
> +            if vcs.pathprefix in srv:
> +                if host == srv[vcs.pathprefix]:
> +                    _rootpath = m.group('root')
> +                    _vcs = srv[vcs.vcs]
> +                    _repourl = m.expand(srv[vcs.repo])
> +                    _suffix = m.group('suffix')
> +                    break
> +            elif m and srv[vcs.schemelessRepo]:
> +                _rootpath = m.group('root')
> +                _vcs = m[vcs.vcs]
> +                _repourl = m[vcs.repo]
> +                _suffix = m.group('suffix')
> +                break
> +
> +        return GoImport(_rootpath, _vcs, _repourl, _suffix)
> +
> +    def _resolve_repository_dynamic(self, modulepath):
> +
> +        url = urllib.parse.urlparse("https://" + modulepath)
> +
> +        class GoImportHTMLParser(HTMLParser):
> +
> +            def __init__(self):
> +                super().__init__()
> +                self.__srv = []
> +
> +            def handle_starttag(self, tag, attrs):
> +                if tag == 'meta' and list(filter(lambda a: (a[0] == 'name' and a[1] == 'go-import'), attrs)):
> +                    content = list(filter(lambda a: (a[0] == 'content'), attrs))
> +                    if content:
> +                        self.__srv = content[0][1].split()
> +
> +            @property
> +            def rootpath(self):
> +                return self.__srv[0]
> +
> +            @property
> +            def vcs(self):
> +                return self.__srv[1]
> +
> +            @property
> +            def repourl(self):
> +                return self.__srv[2]
> +
> +        req = urllib.request.Request(url.geturl() + "?go-get=1")
> +
> +        try:
> +            resp = urllib.request.urlopen(req)
> +        except URLError as url_err:
> +            logger.error("Error while fetching redirect page: %s", str(url_err))
> +            return None
> +        except HTTPError as http_err:
> +            logger.error("Error while fetching redirect page: %s", str(http_err))
> +            return None
> +
> +        parser = GoImportHTMLParser()
> +        parser.feed(resp.read().decode('utf-8'))
> +        parser.close()
> +
> +        return GoImport(parser.rootpath, parser.vcs, parser.repourl, None)
> +
> +    def _resolve_repository(self, modulepath):
> +        """
> +        Resolves src uri from go module-path
> +        """
> +        repodata = self._resolve_repository_static(modulepath)
> +        if not repodata.repourl:
> +            repodata = self._resolve_repository_dynamic(modulepath)
> +
> +        if repodata:
> +            logger.info("Resolved download path for import '%s' => %s", modulepath, repodata.repourl)
> +
> +        return repodata
> +
> +    def _resolve_pseudo_semver(self, d, repo, module_version):
> +        hash = None
> +
> +        def vcs_fetch_all():
> +            tmpdir = tempfile.mkdtemp()
> +            clone_cmd = "%s clone --bare %s %s" % ('git', repo, tmpdir)
> +            bb.process.run(clone_cmd)
> +            log_cmd = "git log --all --pretty='%H %d' --decorate=short"
> +            output, errors = bb.process.run(log_cmd, shell=True, stderr=subprocess.PIPE, cwd=tmpdir)
> +            bb.utils.prunedir(tmpdir)
> +            return output.strip().split('\n')
> +
> +        def vcs_fetch_remote(search=""):
> +            ls_remote_cmd = "git ls-remote --tags {} {}".format(repo, search)
> +            output, errors = bb.process.run(ls_remote_cmd)
> +            return output.strip().split('\n')
> +
> +        m_pseudo_semver = re_pseudo_semver.match(module_version)
> +        if m_pseudo_semver:
> +            remote_refs = vcs_fetch_all()
> +            short_commit = m_pseudo_semver.group('sha1_abbrev')
> +            for l in remote_refs:
> +                r = l.split(maxsplit=1)
> +                sha1 = r[0] if len(r) else None
> +                if not sha1:
> +                    logger.error("Ups: could not resolve abbref commit for %s" % short_commit)
> +
> +                elif sha1.startswith(short_commit):
> +                    hash = sha1
> +                    break
> +        else:
> +            m_semver = re_semver.match(module_version)
> +            if m_semver:
> +
> +                def get_sha1_remote(re, groupId):
> +                    for l in remote_refs:
> +                        r = l.split(maxsplit=1)
> +                        sha1 = r[0] if len(r) else None
> +                        ref = r[1] if len(r) == 2 else None
> +                        if ref:
> +                            m = re.match(ref)
> +                            if m and semver_tag in m.group(groupId).split(','):
> +                                return sha1
> +                    return None
> +
> +                re_tags_remote = re.compile(r"refs/tags/(?P<tag>[0-9A-Za-z-_\.]+)")
> +                re_tags_all = re.compile(r"\((HEAD -> (.*), )?tag: *((?:([0-9A-Za-z-_\.]+),? *)+)\)")
> +                semver_tag = "v" + m_semver.group('major') + "."\
> +                                +m_semver.group('minor') + "."\
> +                                +m_semver.group('patch') \
> +                                +(("-" + m_semver.group('prerelease')) if m_semver.group('prerelease') else "")
> +                remote_refs = vcs_fetch_remote(semver_tag)
> +                # probe tag using 'ls-remote', which is faster than fetching complete history
> +                sha1 = get_sha1_remote(re_tags_remote, 'tag')
> +                if sha1:
> +                    hash = sha1
> +                else:
> +                    # backup: fetch complete history
> +                    remote_refs = vcs_fetch_all()
> +                    hash = get_sha1_remote(re_tags_all, 3)
> +        return hash
> +
> +    def _handle_dependencies(self, d, srctree, go_mod):
> +        runenv = dict(os.environ, PATH=d.getVar('PATH'))
> +        src_uris = []
> +        src_revs = []
> +        for require in go_mod['Require']:
> +            module_path = require['Path']
> +            module_version = require['Version']
> +
> +            repodata = self._resolve_repository(module_path)
> +            commit_id = self._resolve_pseudo_semver(d, repodata.repourl, module_version)
> +            url = urllib.parse.urlparse(repodata.repourl)
> +            repo_url = url.netloc + url.path
> +            inline_fcn = "${@go_src_uri("
> +            inline_fcn += "'{}'".format(repo_url)
> +            if repo_url != module_path:
> +                inline_fcn += ",path='{}'".format(module_path)
> +            if repodata.suffix and not re.match("v[0-9]+", repodata.suffix):
> +                inline_fcn += ",subdir='{}'".format(repodata.suffix)
> +            if repodata.vcs != 'git':
> +                inline_fcn += ",vcs='{}'".format(repodata.vcs)
> +            inline_fcn += ")}"
> +
> +            src_uris.append(inline_fcn)
> +            flat_module_path = module_path.replace('/', '.')
> +            src_rev = "# %s@%s => %s\n" % (module_path, module_version, commit_id)
> +            src_rev += "SRCREV_%s = \"%s\"\n" % (flat_module_path, commit_id)
> +            src_rev += "GO_MODULE_PATH[%s] = \"%s\"\n" % (flat_module_path, module_path)
> +            src_rev += "GO_MODULE_VERSION[%s] = \"%s\"" % (flat_module_path, module_version)
> +            src_revs.append(src_rev)
> +
> +        return src_uris, src_revs
> +
> +    def _go_mod_patch(self, patchfile, go_import, srctree, localfilesdir, extravalues, d):
> +        runenv = dict(os.environ, PATH=d.getVar('PATH'))
> +        # first remove go.mod and go.sum, otherwise 'go mod init' will fail
> +        bb.utils.remove(os.path.join(srctree, "go.mod"))
> +        bb.utils.remove(os.path.join(srctree, "go.sum"))
> +        bb.process.run("go mod init %s" % go_import, stderr=subprocess.STDOUT, env=runenv, shell=True, cwd=srctree)
> +        bb.process.run("go mod tidy", stderr=subprocess.STDOUT, env=runenv, shell=True, cwd=srctree)
> +        output, _ = bb.process.run("go mod edit -json", stderr=subprocess.STDOUT, env=runenv, shell=True, cwd=srctree)
> +        bb.process.run("git diff go.mod > %s" % (patchfile), stderr=subprocess.STDOUT, env=runenv, shell=True, cwd=srctree)
> +        bb.process.run("git checkout HEAD go.mod go.sum;", stderr=subprocess.STDOUT, env=runenv, shell=True, cwd=srctree)
> +        go_mod = json.loads(output)
> +        tmpfile = os.path.join(localfilesdir, patchfile)
> +        shutil.move(os.path.join(srctree, patchfile), tmpfile)
> +        extravalues.setdefault('extrafiles', {})
> +        extravalues['extrafiles'][patchfile] = tmpfile
> +
> +        return go_mod
> +
> +    def process(self, srctree, classes, lines_before, lines_after, handled, extravalues):
> +
> +        if 'buildsystem' in handled:
> +            return False
> +
> +        files = RecipeHandler.checkfiles(srctree, ['go.mod'])
> +        if not files:
> +            return False
> +
> +        go_bindir = ensure_native_cmd(tinfoil, "go")
> +
> +        d = bb.data.createCopy(tinfoil.config_data)
> +        d.prependVar('PATH', '%s:' % go_bindir)
> +        handled.append('buildsystem')
> +        classes.append("go-vendor")
> +
> +        runenv = dict(os.environ, PATH=d.getVar('PATH'))
> +        output, _ = bb.process.run("go mod edit -json", stderr=subprocess.STDOUT, env=runenv, shell=True, cwd=srctree)
> +        go_mod = json.loads(output)
> +
> +        go_import = go_mod['Module']['Path']
> +        go_version_match = re.match("([0-9]+).([0-9]+)", go_mod['Go'])
> +        go_version_major = int(go_version_match.group(1))
> +        go_version_minor = int(go_version_match.group(2))
> +        src_uris = []
> +        if go_version_major == 1 and go_version_minor < 17:
> +            logger.warning("go.mod files generated by Go < 1.17 might have incomplete indirect dependencies.")
> +            patchfile = "go.mod.patch"
> +            localfilesdir = tempfile.mkdtemp(prefix='recipetool-go-')
> +            go_mod = self._go_mod_patch(patchfile, go_import, srctree, localfilesdir, extravalues, d)
> +            src_uris.append("file://%s;patchdir=src/${GO_IMPORT}" % (patchfile))
> +
> +        if not os.path.exists(os.path.join(srctree, "vendor")):
> +            dep_src_uris, src_revs = self._handle_dependencies(d, srctree, go_mod)
> +            src_uris.extend(dep_src_uris)
> +            lines_after.append("#TODO: Subdirectories are heuristically derived from " \
> +                              "the import path and might be incorrect.")
> +            for src_rev in src_revs:
> +                lines_after.append(src_rev)
> +
> +        self._rewrite_src_uri(src_uris, lines_before)
> +
> +        handle_license_vars(srctree, lines_before, handled, extravalues, d)
> +        self._rewrite_lic_uri(lines_before)
> +
> +        lines_before.append("GO_IMPORT = \"{}\"".format(go_import))
> +        lines_before.append("SRCREV_FORMAT = \"${PN}\"")
> +
> +    def _update_lines_before(self, updated, newlines, lines_before):
> +        if updated:
> +            del lines_before[:]
> +            for line in newlines:
> +                # Hack to avoid newlines that edit_metadata inserts
> +                if line.endswith('\n'):
> +                    line = line[:-1]
> +                lines_before.append(line)
> +        return updated
> +
> +    def _rewrite_lic_uri(self, lines_before):
> +
> +        def varfunc(varname, origvalue, op, newlines):
> +            if varname == 'LIC_FILES_CHKSUM':
> +                new_licenses = []
> +                licenses = origvalue.split()
> +
> +                for license in licenses:
> +                    uri, chksum = license.split(';', 1)
> +                    url = urllib.parse.urlparse(uri)
> +                    new_uri = os.path.join(url.scheme + "://", "src", "${GO_IMPORT}", url.netloc + url.path) + ";" + chksum
> +                    new_licenses.append(new_uri)
> +
> +                return new_licenses, None, -1, True
> +            return origvalue, None, 0, True
> +
> +        updated, newlines = bb.utils.edit_metadata(lines_before, ['LIC_FILES_CHKSUM'], varfunc)
> +        return self._update_lines_before(updated, newlines, lines_before)
> +
> +    def _rewrite_src_uri(self, src_uris_deps, lines_before):
> +
> +        def varfunc(varname, origvalue, op, newlines):
> +            if varname == 'SRC_URI':
> +                src_uri = []
> +                src_uri.append("git://${GO_IMPORT};nobranch=1;name=${PN};protocol=https")
> +                src_uri.extend(src_uris_deps)
> +                return src_uri, None, -1, True
> +            return origvalue, None, 0, True
> +
> +        updated, newlines = bb.utils.edit_metadata(lines_before, ['SRC_URI'], varfunc)
> +        return self._update_lines_before(updated, newlines, lines_before)
> +
> +def register_recipe_handlers(handlers):
> +    handlers.append((GoRecipeHandler(), 60))
> --
> 2.30.2
>
>
> -=-=-=-=-=-=-=-=-=-=-=-
> Links: You receive all messages sent to this group.
> View/Reply Online (#165326): https://lists.openembedded.org/g/openembedded-core/message/165326
> Mute This Topic: https://lists.openembedded.org/mt/90928688/1686489
> Group Owner: openembedded-core+owner@lists.openembedded.org
> Unsubscribe: https://lists.openembedded.org/g/openembedded-core/unsub [alex.kanavin@gmail.com]
> -=-=-=-=-=-=-=-=-=-=-=-
>


  reply	other threads:[~2022-05-06  7:15 UTC|newest]

Thread overview: 15+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2022-05-06  6:59 [PATCH 1/5] recipetool-create: add ensure_native_cmd function Stefan Herbrechtsmeier
2022-05-06  6:59 ` [PATCH 2/5] create_npm: reuse ensure_native_cmd from create.py Stefan Herbrechtsmeier
2022-05-06  6:59 ` [PATCH 3/5] poky-meta: add go vendor class for offline builds Stefan Herbrechtsmeier
2022-05-11 20:42   ` [OE-core] " Mark Asselstine
2022-05-06  6:59 ` [PATCH 4/5] recipetool: add go recipe generator Stefan Herbrechtsmeier
2022-05-06  7:15   ` Alexander Kanavin [this message]
2022-05-11 20:37     ` [OE-core] " Mark Asselstine
2022-05-06  6:59 ` [PATCH 5/5] oe-selftest: add go recipe create selftest Stefan Herbrechtsmeier
2022-05-06  7:16   ` [OE-core] " Alexander Kanavin
2022-05-11 20:08     ` Mark Asselstine
2022-05-06  7:09 ` [OE-core] [PATCH 1/5] recipetool-create: add ensure_native_cmd function Alexander Kanavin
2022-05-11 19:45   ` Mark Asselstine
2022-05-11 19:47     ` Mark Asselstine
2022-05-09 21:46 ` Luca Ceresoli
2022-05-11  6:54   ` Stefan Herbrechtsmeier

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to='CANNYZj-Ve7+CDyrfdNP8rQbYO-JY+yo=n5E8A3EcHLjAyVSHFQ@mail.gmail.com' \
    --to=alex.kanavin@gmail.com \
    --cc=lukas.funke@weidmueller.com \
    --cc=openembedded-core@lists.openembedded.org \
    --cc=stefan.herbrechtsmeier-oss@weidmueller.com \
    --cc=stefan.herbrechtsmeier@weidmueller.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.