From: Jordan Justen <jordan.l.justen@intel.com>
To: Daniil Egranov <daniil.egranov@arm.com>, edk2-devel@lists.01.org
Cc: liming.gao@intel.com, leif.lindholm@linaro.org
Subject: Re: [PATCH v6] BaseTools/Scripts/PatchCheck.py: Extended patch style check for c code
Date: Thu, 15 Dec 2016 22:20:52 -0800 [thread overview]
Message-ID: <148186925220.12840.5824785073245800739@jljusten-ivb> (raw)
In-Reply-To: <1481857737-22433-1-git-send-email-daniil.egranov@arm.com>
Daniil,
Did you see my reply to v5? I don't think you made any changes based
on it.
I would recommend that your next step be to split out the changes to
ignore Contributed-under and Signed-off-by in the cover letter. Submit
just this single new feature as a new patch. Then start doing similar
thing for all the other new features you are introducing.
https://github.com/tianocore/tianocore.github.io/wiki/Commit-Partitioning
But, please also see the other suggestions I made for the v5 version
of the patch.
Can you add me as Cc in the patch commit messages? Since you are using
git-send-email, if you include the maintainers and myself as Cc's in
the commit message, then git-send-email will automatically Cc us on
the email. (you won't need to use the --cc option.) Basically, just
add this into the commit message:
Cc: Yonghong Zhu <yonghong.zhu@intel.com>
Cc: Liming Gao <liming.gao@intel.com>
Cc: Jordan Justen <jordan.l.justen@intel.com>
Here is an example:
https://lists.01.org/pipermail/edk2-devel/2016-December/005379.html
-Jordan
On 2016-12-15 19:08:57, Daniil Egranov wrote:
> Added reporting a source code line number in case of error when
> checking a patch file or using a git sha1 commit string. In case of
> a patch file, both patch and source code line numbers will be provided.
> Corrected handling the case with #define foo(..) macro. The absence of
> the space before open parenthesis is not reported as the error anymore.
> Added checking for lower case "void" and "static" words.
> Added colors for error messages (experimental?)
>
> Contributed-under: TianoCore Contribution Agreement 1.0
> Signed-off-by: Daniil Egranov <daniil.egranov@arm.com>
> ---
> Changelog:
>
> v5
> Corrected code checking for multi-line and commented lines. Both
> multi-line and open comment flags will be reset when leaving
> diff "+" area of the patch.
> Changed version of the tool to 0.2.
>
> v4
> Corrected maximum code line size to 120 characters.
>
> v3
> Corrected space detection before parentheses.
>
> v2:
> Fixed several indentation cases
>
> v1:
> Fixed reporting signature error for a cover letter.
> Fixed reporting line size error for a file change information
> included in the commit message.
> Fixed line number reported in PatchCheck error messages. It
> points to the correct line in the diff file.
> The patch extends style checking for c code:
> Added check for code indentation.
> Added report line size greater than 80 characters.
> Added checking for space before '('.
> Added checking for space before '{'.
> Added checking for '}' to be on a new line and have spaces
> for "} else {" or "} while ()" cases.
>
> BaseTools/Scripts/PatchCheck.py | 344 +++++++++++++++++++++++++++++++++++-----
> 1 file changed, 304 insertions(+), 40 deletions(-)
>
> diff --git a/BaseTools/Scripts/PatchCheck.py b/BaseTools/Scripts/PatchCheck.py
> index 7c30082..8e5ff23 100755
> --- a/BaseTools/Scripts/PatchCheck.py
> +++ b/BaseTools/Scripts/PatchCheck.py
> @@ -15,7 +15,7 @@
>
> from __future__ import print_function
>
> -VersionNumber = '0.1'
> +VersionNumber = '0.2'
> __copyright__ = "Copyright (c) 2015 - 2016, Intel Corporation All rights reserved."
>
> import email
> @@ -25,6 +25,18 @@ import re
> import subprocess
> import sys
>
> +class TextColor:
> + Red = '\033[91m'
> + Green = '\033[92m'
> + Blue = '\033[94m'
> + Cyan = '\033[96m'
> + White = '\033[97m'
> + Yellow = '\033[93m'
> + Magenta = '\033[95m'
> + Grey = '\033[90m'
> + Black = '\033[90m'
> + Default = '\033[0m'
> +
> class Verbose:
> SILENT, ONELINE, NORMAL = range(3)
> level = NORMAL
> @@ -32,7 +44,7 @@ class Verbose:
> class CommitMessageCheck:
> """Checks the contents of a git commit message."""
>
> - def __init__(self, subject, message):
> + def __init__(self, subject, message, message_offset, cover):
> self.ok = True
>
> if subject is None and message is None:
> @@ -41,9 +53,15 @@ class CommitMessageCheck:
>
> self.subject = subject
> self.msg = message
> + self.msg_offset = message_offset
> + self.cover = cover
> +
> + if not cover:
> + self.check_contributed_under()
> + self.check_signed_off_by()
> + else:
> + print('The commit message is cover letter.')
>
> - self.check_contributed_under()
> - self.check_signed_off_by()
> self.check_misc_signatures()
> self.check_overall_format()
> self.report_message_result()
> @@ -60,7 +78,7 @@ class CommitMessageCheck:
> else:
> return_code = 1
> if not self.ok:
> - print(self.url)
> + print(self.url + '\n')
>
> def error(self, *err):
> if self.ok and Verbose.level > Verbose.ONELINE:
> @@ -70,8 +88,8 @@ class CommitMessageCheck:
> return
> count = 0
> for line in err:
> - prefix = (' *', ' ')[count > 0]
> - print(prefix, line)
> + prefix = (TextColor.Red + ' *' + TextColor.Default, ' ')[count > 0]
> + print(prefix, TextColor.Red + line + TextColor.Default)
> count += 1
>
> def check_contributed_under(self):
> @@ -180,6 +198,9 @@ class CommitMessageCheck:
> for sig in self.sig_types:
> self.find_signatures(sig)
>
> + diff_change_info_re = \
> + re.compile(r'.*\|\s+(\d|Bin)*\s.*[\+\-]')
> +
> def check_overall_format(self):
> lines = self.msg.splitlines()
>
> @@ -197,9 +218,10 @@ class CommitMessageCheck:
> self.error('Empty commit message!')
> return
>
> - if count >= 1 and len(lines[0]) >= 72:
> + if count >= 1 and len(lines[0]) > 72:
> self.error('First line of commit message (subject line) ' +
> - 'is too long.')
> + 'is too long (%d) (max 72 characters):' %(len(lines[0])))
> + print(lines[0], '\n')
>
> if count >= 1 and len(lines[0].strip()) == 0:
> self.error('First line of commit message (subject line) ' +
> @@ -210,10 +232,13 @@ class CommitMessageCheck:
> 'empty.')
>
> for i in range(2, count):
> - if (len(lines[i]) >= 76 and
> + if (len(lines[i]) > 76 and
> len(lines[i].split()) > 1 and
> - not lines[i].startswith('git-svn-id:')):
> - self.error('Line %d of commit message is too long.' % (i + 1))
> + not lines[i].startswith('git-svn-id:') and
> + self.diff_change_info_re.search(lines[i]) is None):
> + self.error('Line %d of commit message is too long (%d) (max 76 characters):' \
> + % (i + self.msg_offset - 1, len(lines[i])))
> + print(lines[i], '\n')
>
> last_sig_line = None
> for i in range(count - 1, 0, -1):
> @@ -236,13 +261,21 @@ class CommitMessageCheck:
> class GitDiffCheck:
> """Checks the contents of a git diff."""
>
> - def __init__(self, diff):
> + def __init__(self, diff, offset, patch_type):
> self.ok = True
> self.format_ok = True
> self.lines = diff.splitlines(True)
> self.count = len(self.lines)
> self.line_num = 0
> self.state = START
> + self.comments_open = False
> + self.multiline_string = False
> + self.current_indent_size = 0
> + self.parentheses_count = 0
> + self.offset = offset
> + self.file_type = "other_type"
> + self.patch_type = patch_type
> + self.src_line_num = 0;
> while self.line_num < self.count and self.format_ok:
> line_num = self.line_num
> self.run()
> @@ -255,6 +288,14 @@ class GitDiffCheck:
> if self.ok:
> print('The code passed all checks.')
>
> + def clean_counts(self):
> + self.current_indent_size = -1
> + self.parentheses_count = 0
> + self.comments_open = False
> + self.multiline_string = False
> +
> + diff_src_line_start_re = re.compile(r'(\+\d+)')
> +
> def run(self):
> line = self.lines[self.line_num]
>
> @@ -281,9 +322,28 @@ class GitDiffCheck:
> self.format_error("didn't find diff command")
> self.line_num += 1
> elif self.state == PRE_PATCH:
> - if line.startswith('+++ b/'):
> + if line.startswith('+++ '):
> self.set_filename(line[6:].rstrip())
> + print("Checking patch for %s ...\n" %(self.hunk_filename))
> + if self.hunk_filename.endswith(".c") or \
> + self.hunk_filename.endswith(".h"):
> + self.file_type = "c_type"
> + elif self.hunk_filename.endswith(".dec") or \
> + self.hunk_filename.endswith(".dsc") or \
> + self.hunk_filename.endswith(".fdf"):
> + self.file_type = "edk2_type"
> + elif self.hunk_filename.endswith(".S"):
> + self.file_type = "asm_arm_type"
> + elif self.hunk_filename.endswith(".asm"):
> + self.file_type = "asm_type"
> + else:
> + self.file_type = "other_type"
> if line.startswith('@@ '):
> + sls = self.diff_src_line_start_re.search(line)
> + if sls is not None:
> + self.src_line_num = int(sls.group(0))
> + else:
> + self.src_line_num = 0
> self.state = PATCH
> self.binary = False
> elif line.startswith('GIT binary patch'):
> @@ -299,15 +359,25 @@ class GitDiffCheck:
> self.line_num += 1
> elif self.state == PATCH:
> if self.binary:
> + self.clean_counts()
> pass
> if line.startswith('-'):
> + self.clean_counts()
> pass
> elif line.startswith('+'):
> - self.check_added_line(line[1:])
> + # indentation resets every time we leave "+" diff area
> + self.check_added_line(line[1:], self.line_num + self.offset,
> + self.src_line_num)
> + self.src_line_num +=1
> elif line.startswith(r'\ No newline '):
> + self.clean_counts()
> pass
> elif not line.startswith(' '):
> + self.clean_counts()
> self.format_error("unexpected patch line")
> + else:
> + self.src_line_num +=1
> + self.clean_counts()
> self.line_num += 1
>
> pre_patch_prefixes = (
> @@ -332,13 +402,17 @@ class GitDiffCheck:
> else:
> self.force_crlf = True
>
> - def added_line_error(self, msg, line):
> + def added_line_error(self, msg, line, diff_line_num, src_line_num, patch_type):
> lines = [ msg ]
> - if self.hunk_filename is not None:
> - lines.append('File: ' + self.hunk_filename)
> - lines.append('Line: ' + line)
> -
> self.error(*lines)
> + if self.hunk_filename is not None:
> + print('File: ' + self.hunk_filename)
> + line_info = 'Line '
> + if patch_type == 'patch_file_type':
> + line_info += ('%d(patch) ' %(diff_line_num))
> + line_info += ('%d(source) ' %(src_line_num))
> + line_info += (':\n' + line + '\n')
> + print(line_info)
>
> old_debug_re = \
> re.compile(r'''
> @@ -348,28 +422,195 @@ class GitDiffCheck:
> ''',
> re.VERBOSE)
>
> - def check_added_line(self, line):
> + space_check_parentheses_re = re.compile(r'#define\s*[a-zA-Z0-9_]\S*\(|[a-zA-Z0-9_]\S*\((?<!\)\()(?<!\(\()')
> + space_check_brace_open_re = re.compile(r'[a-zA-Z0-9_]\S*\{')
> + space_check_brace_close_re = re.compile(r'[\}]\S.*')
> + brace_open_check_re = re.compile(r'[a-zA-Z0-9_]*\{')
> + brace_close_check_re = re.compile(r'.*\}.*')
> + multiline_check_re = re.compile(r'[a-zA-Z0-9_]*(\\|\&|\,|\+|\-|\*|\||\=)\Z')
> + void_cap_check_re = re.compile(r'(\s|\b|\()void(\s+|\*|\,|\))')
> + static_cap_check_re = re.compile(r'(\s|\b)static\s+')
> +
> + def check_added_line(self, line, diff_line_num, src_line_num):
> eol = ''
> for an_eol in self.line_endings:
> if line.endswith(an_eol):
> eol = an_eol
> line = line[:-len(eol)]
>
> - stripped = line.rstrip()
> + rstripped = line.rstrip()
> + lstripped = line.lstrip()
>
> if self.force_crlf and eol != '\r\n':
> self.added_line_error('Line ending (%s) is not CRLF' % repr(eol),
> - line)
> + line, diff_line_num, src_line_num, self.patch_type)
> if ' ' in line:
> - self.added_line_error('Tab character used', line)
> - if len(stripped) < len(line):
> - self.added_line_error('Trailing whitespace found', line)
> + self.added_line_error('Tab character used',
> + line, diff_line_num, src_line_num, self.patch_type)
> + if len(rstripped) < len(line):
> + self.added_line_error('Trailing whitespace found',
> + line, diff_line_num, src_line_num, self.patch_type)
> +
> + # empty "+" line, skip it
> + if len(line) == 0:
> + return
>
> - mo = self.old_debug_re.search(line)
> - if mo is not None:
> - self.added_line_error('EFI_D_' + mo.group(1) + ' was used, '
> - 'but DEBUG_' + mo.group(1) +
> - ' is now recommended', line)
> + # the file type specific checks
> + if self.file_type == "c_type":
> +
> + # set initial indentation as the number of spaces after the first "+"
> + # indentation does not apply on lines with 0 characters
> + # adjust the indentation if first "+" has "}"
> + if self.current_indent_size == -1:
> + if len(lstripped) != 0:
> + self.current_indent_size = len(line) - len(lstripped)
> + else:
> + self.clean_counts()
> +
> + if lstripped.startswith("}"):
> + self.current_indent_size = line.find("}") + 2
> +
> + indent_size = len(line) - len(lstripped)
> + indent_size_adjust = 0
> + force_brace_check = False
> +
> + if len(line) > 120:
> + self.added_line_error('Line is too long (%d) (max 120 characters)',
> + line, diff_line_num, src_line_num, self.patch_type)
> +
> + # skip comments for code checking
> + if lstripped.startswith("//"):
> + return
> + if lstripped.startswith("/*") and not self.comments_open:
> + self.comments_open = True
> +
> + if rstripped.endswith("*/") and self.comments_open:
> + self.comments_open = False
> + return
> +
> + if rstripped.endswith("*/") and not self.comments_open:
> + # found unknown close comment, reset the indentation as
> + # no idea where it started
> + self.current_indent_size = -1
> + return
> +
> + if not self.comments_open:
> + mo = self.old_debug_re.search(line)
> + if mo is not None:
> + self.added_line_error('EFI_D_' + mo.group(1) + ' was used, '
> + 'but DEBUG_' + mo.group(1) +
> + ' is now recommended',
> + line, diff_line_num, src_line_num, self.patch_type)
> +
> + # track down multiple braces in the same line
> + braces_count = line.count('{') - line.count('}')
> +
> + # check if line has text in double quotes as we do not
> + # need to check text messages
> + text_str_start = line.find('\"')
> +
> + # track down a special case with "} else ..{" braces
> + if line.find("else") != -1:
> + force_brace_check = True
> +
> + # check for closing brace which affects indentation
> + if braces_count < 0 and not force_brace_check:
> + self.current_indent_size -= 2 * abs(braces_count)
> + elif force_brace_check:
> + if line.find("}") != -1:
> + self.current_indent_size -= 2
> + braces_count = 1
> +
> + # for the switch()/case:, adjust "case:" and "default:" indentation
> + # as they aligned with the "switch()" indentation
> + if lstripped.startswith("case ") or \
> + lstripped.startswith("default:"):
> + indent_size_adjust = -2
> +
> + # indentation code check related to the diff structure
> + # do not check code indentation between parentheses
> + if indent_size != self.current_indent_size + indent_size_adjust \
> + and len(line) != 0 and self.parentheses_count == 0 \
> + and not self.multiline_string:
> + self.added_line_error('Invalid indentation. Expecting %d but found %d spaces' \
> + %(self.current_indent_size + indent_size_adjust, \
> + indent_size),
> + line, diff_line_num, src_line_num, self.patch_type)
> +
> + # check for missing space before open brace
> + if braces_count > 0 or force_brace_check:
> + if line.find("{") != -1:
> + self.current_indent_size += 2 * braces_count
> + if not line.startswith("{"):
> + spb_check = self.space_check_brace_open_re.search(line)
> + if spb_check is not None:
> + self.added_line_error('Missing space before \'{\'',
> + line, diff_line_num, src_line_num, self.patch_type)
> +
> + # check for missing space before open parenthesis
> + spo = self.space_check_parentheses_re.finditer(line)
> + if spo is not None and not self.multiline_string:
> + # check if it's not inside of the double quoted string or
> + # (aa)(bb)fff case or parenthesis belong to #define macro
> + for parstr in spo:
> + if not (text_str_start != -1 and text_str_start < parstr.start()) and \
> + not parstr.group(0).startswith("#define"):
> + self.added_line_error('Missing space before \'(\'',
> + line, diff_line_num, src_line_num, self.patch_type)
> + break;
> +
> + # check for closing brace cases
> + if braces_count < 0 or \
> + (force_brace_check and line.find("}") != -1):
> + if not lstripped.startswith("}") and rstripped.endswith("}"):
> + self.added_line_error('The \'}\' is not on its own line',
> + line, diff_line_num, src_line_num, self.patch_type)
> + if not lstripped.endswith("}"):
> + scb = self.space_check_brace_close_re.search(lstripped)
> + if scb is not None \
> + and lstripped[scb.start() + 1] != ';' \
> + and lstripped[scb.start() + 1] != ',' \
> + and lstripped[scb.start() + 1] != '}':
> + self.added_line_error('Missing space after \'}\'',
> + line, diff_line_num, src_line_num, self.patch_type)
> +
> + # check for using "void" instead of "VOID"
> + vcc = self.void_cap_check_re.search(line)
> + if vcc is not None and \
> + not (text_str_start != -1 and text_str_start < vcc.start()):
> + self.added_line_error('Using \"void\" instead of \"VOID\"',
> + line, diff_line_num, src_line_num, self.patch_type)
> +
> + #check for using "static" instead of "STATIC"
> + scc = self.static_cap_check_re.search(line)
> + if scc is not None and \
> + not (text_str_start != -1 and text_str_start < scc.start()):
> + self.added_line_error('Using \"static\" instead of \"STATIC\"',
> + line, diff_line_num, src_line_num, self.patch_type)
> +
> + # track down parentheses, no indentation check between them
> + if not (self.parentheses_count == 0 and lstripped.startswith(')') \
> + and not self.multiline_string and not self.comments_open):
> + self.parentheses_count += line.count('(') - line.count(')')
> +
> + # multiline strings indentation is unknown
> + if not self.comments_open:
> + mlc = self.multiline_check_re.search(line)
> + if mlc is not None:
> + self.multiline_string = True
> + else:
> + self.multiline_string = False
> +
> + elif self.file_type == "edk2_type":
> + pass
> + elif self.file_type == "asm_arm_type":
> + pass
> + elif self.file_type == "asm_type":
> + pass
> + elif self.file_type == "other_type":
> + pass
> + else:
> + pass
>
> split_diff_re = re.compile(r'''
> (?P<cmd>
> @@ -395,8 +636,8 @@ class GitDiffCheck:
> return
> count = 0
> for line in err:
> - prefix = (' *', ' ')[count > 0]
> - print(prefix, line)
> + prefix = (TextColor.Red + ' *' + TextColor.Default, ' ')[count > 0]
> + print(prefix, TextColor.Red + line + TextColor.Default)
> count += 1
>
> class CheckOnePatch:
> @@ -406,16 +647,17 @@ class CheckOnePatch:
> patch content.
> """
>
> - def __init__(self, name, patch):
> + def __init__(self, name, patch, patch_type):
> self.patch = patch
> self.find_patch_pieces()
>
> - msg_check = CommitMessageCheck(self.commit_subject, self.commit_msg)
> + msg_check = CommitMessageCheck(self.commit_subject, self.commit_msg, \
> + self.commit_msg_offset, self.cover)
> msg_ok = msg_check.ok
>
> diff_ok = True
> if self.diff is not None:
> - diff_check = GitDiffCheck(self.diff)
> + diff_check = GitDiffCheck(self.diff, self.diff_offset, patch_type)
> diff_ok = diff_check.ok
>
> self.ok = msg_ok and diff_ok
> @@ -458,6 +700,16 @@ class CheckOnePatch:
> ''',
> re.VERBOSE)
>
> + cover_letter_re = re.compile(r'Subject:\s*\[PATCH.*.0/.*',
> + re.IGNORECASE | re.MULTILINE)
> +
> + def is_cover_letter(self, patch):
> + cl = self.cover_letter_re.search(patch)
> + if cl is None:
> + return False
> +
> + return True
> +
> def find_patch_pieces(self):
> if sys.version_info < (3, 0):
> patch = self.patch.encode('ascii', 'ignore')
> @@ -465,10 +717,13 @@ class CheckOnePatch:
> patch = self.patch
>
> self.commit_msg = None
> + self.commit_msg_offset = 0
> self.stat = None
> self.commit_subject = None
> self.commit_prefix = None
> self.diff = None
> + self.diff_offset = 0
> + self.cover = False
>
> if patch.startswith('diff --git'):
> self.diff = patch
> @@ -480,6 +735,14 @@ class CheckOnePatch:
> assert(parts[0].get_content_type() == 'text/plain')
> content = parts[0].get_payload(decode=True).decode('utf-8', 'ignore')
>
> + # find offset of content
> + self.commit_msg_offset = len(patch.splitlines()) - len(content.splitlines())
> +
> + # find offset of first diff section
> + mo = self.git_diff_re.search(patch)
> + if mo is not None:
> + self.diff_offset = len(patch[:mo.start()].splitlines()) + 1
> +
> mo = self.git_diff_re.search(content)
> if mo is not None:
> self.diff = content[mo.start():]
> @@ -492,6 +755,7 @@ class CheckOnePatch:
> self.stat = mo.group('stat')
> self.commit_msg = mo.group('commit_message')
>
> + self.cover = self.is_cover_letter(patch)
> self.commit_subject = pmail['subject'].replace('\r\n', '')
> self.commit_subject = self.commit_subject.replace('\n', '')
> self.commit_subject = self.subject_prefix_re.sub('', self.commit_subject, 1)
> @@ -514,9 +778,9 @@ class CheckGitCommits:
> print()
> else:
> blank_line = True
> - print('Checking git commit:', commit)
> + print('Checking git commit:', commit, '\n')
> patch = self.read_patch_from_git(commit)
> - self.ok &= CheckOnePatch(commit, patch).ok
> + self.ok &= CheckOnePatch(commit, patch, "git_commit_type").ok
>
> def read_commit_list_from_git(self, rev_spec, max_count):
> # Run git to get the commit patch
> @@ -554,8 +818,8 @@ class CheckOnePatchFile:
> patch = f.read().decode('utf-8', 'ignore')
> f.close()
> if Verbose.level > Verbose.ONELINE:
> - print('Checking patch file:', patch_filename)
> - self.ok = CheckOnePatch(patch_filename, patch).ok
> + print('Checking patch file:', patch_filename, '\n')
> + self.ok = CheckOnePatch(patch_filename, patch, "patch_file_type").ok
>
> class CheckOneArg:
> """Performs a patch check for a single command line argument.
> --
> 2.7.4
>
> _______________________________________________
> edk2-devel mailing list
> edk2-devel@lists.01.org
> https://lists.01.org/mailman/listinfo/edk2-devel
prev parent reply other threads:[~2016-12-16 6:20 UTC|newest]
Thread overview: 3+ messages / expand[flat|nested] mbox.gz Atom feed top
2016-12-16 3:08 [PATCH v6] BaseTools/Scripts/PatchCheck.py: Extended patch style check for c code Daniil Egranov
2016-12-16 5:28 ` Gao, Liming
2016-12-16 6:20 ` Jordan Justen [this message]
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-list 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=148186925220.12840.5824785073245800739@jljusten-ivb \
--to=devel@edk2.groups.io \
/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 a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox