internal/commentfix: regexp-based comment editor

Package commentfix implements rule-based automated
editing of GitHub comments, such as to fix or add links.

Copied from https://github.com/rsc/gaby/commit/982e304.

Change-Id: If9dd16023fd628f1431244617a6ce288f41039d0
Reviewed-on: https://go-review.googlesource.com/c/oscar/+/597150
Reviewed-by: Ian Lance Taylor <iant@google.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Auto-Submit: Russ Cox <rsc@golang.org>
diff --git a/go.mod b/go.mod
index 3d335ef..01e8dfc 100644
--- a/go.mod
+++ b/go.mod
@@ -7,6 +7,7 @@
 	github.com/google/generative-ai-go v0.16.0
 	golang.org/x/tools v0.23.0
 	google.golang.org/api v0.186.0
+	rsc.io/markdown v0.0.0-20240617154923-1f2ef1438fed
 	rsc.io/omap v1.2.1-0.20240709133045-40dad5c0c0fb
 	rsc.io/ordered v1.1.0
 	rsc.io/top v1.0.2
diff --git a/go.sum b/go.sum
index 0abe005..d74f0a4 100644
--- a/go.sum
+++ b/go.sum
@@ -273,6 +273,8 @@
 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.6.0 h1:boZcn2GTjpsynOsC0iJHnBWa4Bi0qzfJjthwauItG68=
+github.com/yuin/goldmark v1.6.0/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
 go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
 go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
 go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
@@ -592,6 +594,8 @@
 honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
 honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
 rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
+rsc.io/markdown v0.0.0-20240617154923-1f2ef1438fed h1:savaUwUp0YCIxdaF9EFOMB3j+TQnoLop+cNp2KPC9jk=
+rsc.io/markdown v0.0.0-20240617154923-1f2ef1438fed/go.mod h1:rzOcjAz36Xzvwf6iaJSYXkmNbvu5XHelis1egIN0Cys=
 rsc.io/omap v1.2.1-0.20240709133045-40dad5c0c0fb h1:+2CTPs/tT0t54s9f3vxDUzss6XUKC6C+Z6cDCfV5V38=
 rsc.io/omap v1.2.1-0.20240709133045-40dad5c0c0fb/go.mod h1:V4A0Qx0A9nvl4IPmfBYIKdaH019Oq3Ygnb7fA92uN1U=
 rsc.io/ordered v1.1.0 h1:9U9go719kAa92IjYlnBFNjg2HKuVBflx8Y6gkv8kcvw=
diff --git a/internal/commentfix/fix.go b/internal/commentfix/fix.go
new file mode 100644
index 0000000..7738f8f
--- /dev/null
+++ b/internal/commentfix/fix.go
@@ -0,0 +1,497 @@
+// Copyright 2024 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Package commentfix implements rule-based rewriting of issue comments.
+package commentfix
+
+import (
+	"fmt"
+	"io"
+	"log/slog"
+	"os"
+	"reflect"
+	"regexp"
+	"strings"
+	"testing"
+	"time"
+
+	"golang.org/x/oscar/internal/diff"
+	"golang.org/x/oscar/internal/github"
+	"golang.org/x/oscar/internal/storage/timed"
+	"rsc.io/markdown"
+)
+
+// A Fixer rewrites issue texts and issue comments using a set of rules.
+// After creating a fixer with [New], new rules can be added using
+// the [Fixer.AutoLink], [Fixer.ReplaceText], and [Fixer.ReplaceURL] methods,
+// and then repeated calls to [Fixer.Run] apply the replacements on GitHub.
+//
+// The zero value of a Fixer can be used in “offline” mode with [Fixer.Fix],
+// which returns rewritten Markdown.
+//
+// TODO(rsc): Separate the GitHub logic more cleanly from the rewrite logic.
+type Fixer struct {
+	slog      *slog.Logger
+	github    *github.Client
+	watcher   *timed.Watcher[*github.Event]
+	fixes     []func(any, int) any
+	projects  map[string]bool
+	edit      bool
+	timeLimit time.Time
+
+	stderrw io.Writer
+}
+
+func (f *Fixer) stderr() io.Writer {
+	if f.stderrw != nil {
+		return f.stderrw
+	}
+	return os.Stderr
+}
+
+// SetStderr sets the writer to use for messages f intends to print to standard error.
+// A Fixer writes directly to standard error (or this writer) so that it can print
+// readable multiline debugging outputs. These are also logged via the slog.Logger
+// passed to New, but multiline strings format as one very long Go-quoted string in slog
+// and are not as easy to read.
+func (f *Fixer) SetStderr(w io.Writer) {
+	f.stderrw = w
+}
+
+// New creates a new Fixer using the given logger and GitHub client.
+//
+// The Fixer logs status and errors to lg; if lg is nil, the Fixer does not log anything.
+//
+// The GitHub client is used to watch for new issues and comments
+// and to edit issues and comments. If gh is nil, the Fixer can still be
+// configured and applied to Markdown using [Fixer.Fix], but calling
+// [Fixer.Run] will panic.
+//
+// The name is the handle by which the Fixer's “last position” is retrieved
+// across multiple program invocations; each differently configured
+// Fixer needs a different name.
+func New(lg *slog.Logger, gh *github.Client, name string) *Fixer {
+	f := &Fixer{
+		slog:      lg,
+		github:    gh,
+		projects:  make(map[string]bool),
+		timeLimit: time.Now().Add(-30 * 24 * time.Hour),
+	}
+	f.init() // set f.slog if lg==nil
+	if gh != nil {
+		f.watcher = gh.EventWatcher("commentfix.Fixer:" + name)
+	}
+	return f
+}
+
+// SetTimeLimit sets the time before which comments are not edited.
+func (f *Fixer) SetTimeLimit(limit time.Time) {
+	f.timeLimit = limit
+}
+
+// init makes sure slog is non-nil.
+func (f *Fixer) init() {
+	if f.slog == nil {
+		f.slog = slog.New(slog.NewTextHandler(io.Discard, nil))
+	}
+}
+
+func (f *Fixer) EnableProject(name string) {
+	f.init()
+	if f.github == nil {
+		panic("commentfix.Fixer: EnableProject missing GitHub client")
+	}
+	f.projects[name] = true
+}
+
+// EnableEdits configures the fixer to make edits to comments on GitHub.
+// If EnableEdits is not called, the Fixer only prints what it would do,
+// and it does not mark the issues and comments as “old”.
+// This default mode is useful for experimenting with a Fixer
+// to gauge its effects.
+//
+// EnableEdits panics if the Fixer was not constructed by calling [New]
+// with a non-nil [github.Client].
+func (f *Fixer) EnableEdits() {
+	f.init()
+	if f.github == nil {
+		panic("commentfix.Fixer: EnableEdits missing GitHub client")
+	}
+	f.edit = true
+}
+
+// AutoLink instructs the fixer to turn any text matching the
+// regular expression pattern into a link to the URL.
+// The URL can contain substitution values like $1
+// as supported by [regexp.Regexp.Expand].
+//
+// For example, to link CL nnn to https://go.dev/cl/nnn,
+// you could use:
+//
+//	f.AutoLink(`\bCL (\d+)\b`, "https://go.dev/cl/$1")
+func (f *Fixer) AutoLink(pattern, url string) error {
+	f.init()
+	re, err := regexp.Compile(pattern)
+	if err != nil {
+		return err
+	}
+	f.fixes = append(f.fixes, func(x any, flags int) any {
+		if flags&flagLink != 0 {
+			// already inside link
+			return nil
+		}
+		plain, ok := x.(*markdown.Plain)
+		if !ok {
+			return nil
+		}
+		var out []markdown.Inline
+		start := 0
+		text := plain.Text
+		for _, m := range re.FindAllStringSubmatchIndex(text, -1) {
+			if start < m[0] {
+				out = append(out, &markdown.Plain{Text: text[start:m[0]]})
+			}
+			link := string(re.ExpandString(nil, url, text, m))
+			out = append(out, &markdown.Link{
+				Inner: []markdown.Inline{&markdown.Plain{Text: text[m[0]:m[1]]}},
+				URL:   link,
+			})
+			start = m[1]
+		}
+		if start == 0 {
+			return nil
+		}
+		if start < len(text) {
+			out = append(out, &markdown.Plain{Text: text[start:]})
+		}
+		return out
+	})
+	return nil
+}
+
+// ReplaceText instructs the fixer to replace any text
+// matching the regular expression pattern with the replacement repl.
+// The replacement can contain substitution values like $1
+// as supported by [regexp.Regexp.Expand].
+//
+// ReplaceText only applies in Markdown plain text.
+// It does not apply in backticked code text, or in backticked
+// or indented code blocks, or to URLs.
+// It does apply to the plain text inside headings,
+// inside bold, italic, or link markup.
+//
+// For example, you could correct “cancelled” to “canceled”,
+// following Go's usual conventions, with:
+//
+//	f.ReplaceText(`cancelled`, "canceled")
+func (f *Fixer) ReplaceText(pattern, repl string) error {
+	f.init()
+	re, err := regexp.Compile(pattern)
+	if err != nil {
+		return err
+	}
+	f.fixes = append(f.fixes, func(x any, flags int) any {
+		plain, ok := x.(*markdown.Plain)
+		if !ok {
+			return nil
+		}
+		if re.FindStringSubmatchIndex(plain.Text) == nil {
+			return nil
+		}
+		plain.Text = re.ReplaceAllString(plain.Text, repl)
+		return plain
+	})
+	return nil
+}
+
+// ReplaceURL instructs the fixer to replace any linked URLs
+// matching the regular expression pattern with the replacement URL repl.
+// The replacement can contain substitution values like $1
+// as supported by [regexp.Regexp.Expand].
+//
+// The regular expression pattern is automatically anchored
+// to the start of the URL: there is no need to start it with \A or ^.
+//
+// For example, to replace links to golang.org with links to go.dev,
+// you could use:
+//
+//	f.ReplaceURL(`https://golang\.org(/?)`, "https://go.dev$1")
+func (f *Fixer) ReplaceURL(pattern, repl string) error {
+	f.init()
+	re, err := regexp.Compile(`\A(?:` + pattern + `)`)
+	if err != nil {
+		return err
+	}
+	f.fixes = append(f.fixes, func(x any, flags int) any {
+		switch x := x.(type) {
+		case *markdown.AutoLink:
+			old := x.URL
+			x.URL = re.ReplaceAllString(x.URL, repl)
+			if x.URL == old {
+				return nil
+			}
+			if x.Text == old {
+				x.Text = x.URL
+			}
+			return x
+		case *markdown.Link:
+			old := x.URL
+			x.URL = re.ReplaceAllString(x.URL, repl)
+			if x.URL == old {
+				return nil
+			}
+			if len(x.Inner) == 1 {
+				if p, ok := x.Inner[0].(*markdown.Plain); ok && p.Text == old {
+					p.Text = x.URL
+				}
+			}
+			return x
+		}
+		return nil
+	})
+	return nil
+}
+
+// Run applies the configured rewrites to issue texts and comments on GitHub
+// that have been updated since the last call to Run for this fixer with edits enabled
+// (including in different program invocations using the same fixer name).
+//
+// By default, Run ignores issues texts and comments more than 30 days old.
+// Use [Fixer.SetTimeLimit] to change the cutoff.
+//
+// Run prints diffs of its edits to standard error in addition to logging them,
+// because slog logs the diffs as single-line Go quoted strings that are
+// too difficult to skim.
+//
+// If [Fixer.EnableEdits] has not been called, Run processes recent issue texts
+// and comments and prints diffs of its intended edits to standard error,
+// but it does not make the changes. It also does not mark the issues and comments as processed,
+// so that a future call to Run with edits enabled can rewrite them on GitHub.
+//
+// Run sleeps for 1 second after each GitHub edit.
+//
+// Run panics if the Fixer was not constructed by calling [New]
+// with a non-nil [github.Client].
+func (f *Fixer) Run() {
+	if f.watcher == nil {
+		panic("commentfix.Fixer: Run missing GitHub client")
+	}
+	for e := range f.watcher.Recent() {
+		if !f.projects[e.Project] {
+			continue
+		}
+		var ic *issueOrComment
+		switch x := e.Typed.(type) {
+		default:
+			continue
+		case *github.Issue:
+			if x.PullRequest != nil {
+				// Do not edit pull request bodies,
+				// because they turn into commit messages
+				// and cannot contain things like hyperlinks.
+				continue
+			}
+			ic = &issueOrComment{issue: x}
+		case *github.IssueComment:
+			ic = &issueOrComment{comment: x}
+		}
+		if tm, err := time.Parse(time.RFC3339, ic.updatedAt()); err == nil && tm.Before(f.timeLimit) {
+			if f.edit {
+				f.watcher.MarkOld(e.DBTime)
+			}
+			continue
+		}
+		body, updated := f.Fix(ic.body())
+		if !updated {
+			continue
+		}
+		live, err := ic.download(f.github)
+		if err != nil {
+			// unreachable unless github error
+			f.slog.Error("commentfix download error", "project", e.Project, "issue", e.Issue, "url", ic.url(), "err", err)
+			continue
+		}
+		if live.body() != ic.body() {
+			f.slog.Info("commentfix stale", "project", e.Project, "issue", e.Issue, "url", ic.url())
+			continue
+		}
+		f.slog.Info("commentfix rewrite", "project", e.Project, "issue", e.Issue, "url", ic.url(), "edit", f.edit, "diff", bodyDiff(ic.body(), body))
+		fmt.Fprintf(f.stderr(), "Fix %s:\n%s\n", ic.url(), bodyDiff(ic.body(), body))
+		if f.edit {
+			f.slog.Info("commentfix editing github", "url", ic.url())
+			if err := ic.editBody(f.github, body); err != nil {
+				// unreachable unless github error
+				f.slog.Error("commentfix edit", "project", e.Project, "issue", e.Issue, "err", err)
+				continue
+			}
+			f.watcher.MarkOld(e.DBTime)
+			f.watcher.Flush()
+			if !testing.Testing() {
+				// unreachable in tests
+				time.Sleep(1 * time.Second)
+			}
+		}
+	}
+}
+
+type issueOrComment struct {
+	issue   *github.Issue
+	comment *github.IssueComment
+}
+
+func (ic *issueOrComment) updatedAt() string {
+	if ic.issue != nil {
+		return ic.issue.UpdatedAt
+	}
+	return ic.comment.UpdatedAt
+}
+
+func (ic *issueOrComment) body() string {
+	if ic.issue != nil {
+		return ic.issue.Body
+	}
+	return ic.comment.Body
+}
+
+func (ic *issueOrComment) download(gh *github.Client) (*issueOrComment, error) {
+	if ic.issue != nil {
+		live, err := gh.DownloadIssue(ic.issue.URL)
+		return &issueOrComment{issue: live}, err
+	}
+	live, err := gh.DownloadIssueComment(ic.comment.URL)
+	return &issueOrComment{comment: live}, err
+}
+
+func (ic *issueOrComment) url() string {
+	if ic.issue != nil {
+		return ic.issue.URL
+	}
+	return ic.comment.URL
+}
+
+func (ic *issueOrComment) editBody(gh *github.Client, body string) error {
+	if ic.issue != nil {
+		return gh.EditIssue(ic.issue, &github.IssueChanges{Body: body})
+	}
+	return gh.EditIssueComment(ic.comment, &github.IssueCommentChanges{Body: body})
+}
+
+// Fix applies the configured rewrites to the markdown text.
+// If no fixes apply, it returns "", false.
+// If any fixes apply, it returns the updated text and true.
+func (f *Fixer) Fix(text string) (newText string, fixed bool) {
+	p := &markdown.Parser{
+		AutoLinkText:  true,
+		Strikethrough: true,
+		HeadingIDs:    true,
+		Emoji:         true,
+	}
+	doc := p.Parse(text)
+	for _, fixer := range f.fixes {
+		if f.fixOne(fixer, doc) {
+			fixed = true
+		}
+	}
+	if !fixed {
+		return "", false
+	}
+	return markdown.Format(doc), true
+}
+
+const (
+	// flagLink means this inline is link text,
+	// so it is inappropriate/impossible to turn
+	// it into a (nested) hyperlink.
+	flagLink = 1 << iota
+)
+
+// fixOne runs one fix function over doc,
+// reporting whether doc was changed.
+func (f *Fixer) fixOne(fix func(any, int) any, doc *markdown.Document) (fixed bool) {
+	var (
+		fixBlock   func(markdown.Block)
+		fixInlines func(*[]markdown.Inline)
+	)
+	fixBlock = func(x markdown.Block) {
+		switch x := x.(type) {
+		case *markdown.Document:
+			for _, sub := range x.Blocks {
+				fixBlock(sub)
+			}
+		case *markdown.Quote:
+			for _, sub := range x.Blocks {
+				fixBlock(sub)
+			}
+		case *markdown.List:
+			for _, sub := range x.Items {
+				fixBlock(sub)
+			}
+		case *markdown.Item:
+			for _, sub := range x.Blocks {
+				fixBlock(sub)
+			}
+		case *markdown.Heading:
+			fixBlock(x.Text)
+		case *markdown.Paragraph:
+			fixBlock(x.Text)
+		case *markdown.Text:
+			fixInlines(&x.Inline)
+		}
+	}
+
+	link := 0
+	fixInlines = func(inlines *[]markdown.Inline) {
+		changed := false
+		var out []markdown.Inline
+		for _, x := range *inlines {
+			switch x := x.(type) {
+			case *markdown.Del:
+				fixInlines(&x.Inner)
+			case *markdown.Emph:
+				fixInlines(&x.Inner)
+			case *markdown.Strong:
+				fixInlines(&x.Inner)
+			case *markdown.Link:
+				link++
+				fixInlines(&x.Inner)
+				link--
+			}
+			flags := 0
+			if link > 0 {
+				flags = flagLink
+			}
+			switch fx := fix(x, flags).(type) {
+			default:
+				// unreachable unless bug in fix func
+				f.slog.Error("fixer returned invalid type", "old", reflect.TypeOf(x).String(), "new", reflect.TypeOf(fx).String())
+				out = append(out, x)
+			case nil:
+				out = append(out, x)
+			case markdown.Inline:
+				changed = true
+				out = append(out, fx)
+			case []markdown.Inline:
+				changed = true
+				out = append(out, fx...)
+			}
+		}
+		if changed {
+			*inlines = out
+			fixed = true
+		}
+	}
+
+	fixBlock(doc)
+	return fixed
+}
+
+func bodyDiff(old, new string) string {
+	old = strings.TrimRight(old, "\n") + "\n"
+	old = strings.ReplaceAll(old, "\r\n", "\n")
+
+	new = strings.TrimRight(new, "\n") + "\n"
+	new = strings.ReplaceAll(new, "\r\n", "\n")
+
+	return string(diff.Diff("old", []byte(old), "new", []byte(new)))
+}
diff --git a/internal/commentfix/fix_test.go b/internal/commentfix/fix_test.go
new file mode 100644
index 0000000..cf63f48
--- /dev/null
+++ b/internal/commentfix/fix_test.go
@@ -0,0 +1,233 @@
+// Copyright 2024 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package commentfix
+
+import (
+	"bytes"
+	"io"
+	"path/filepath"
+	"strings"
+	"testing"
+	"text/template"
+	"time"
+
+	"golang.org/x/oscar/internal/diff"
+	"golang.org/x/oscar/internal/github"
+	"golang.org/x/oscar/internal/storage"
+	"golang.org/x/oscar/internal/testutil"
+	"golang.org/x/tools/txtar"
+)
+
+func TestTestdata(t *testing.T) {
+	files, err := filepath.Glob("testdata/*.txt")
+	testutil.Check(t, err)
+	for _, file := range files {
+		t.Run(filepath.Base(file), func(t *testing.T) {
+			a, err := txtar.ParseFile(file)
+			testutil.Check(t, err)
+			var f Fixer
+			tmpl, err := new(template.Template).Parse(string(a.Comment))
+			testutil.Check(t, err)
+			testutil.Check(t, tmpl.Execute(io.Discard, &f))
+			for i := 0; i+2 <= len(a.Files); {
+				in := a.Files[i]
+				out := a.Files[i+1]
+				i += 2
+				name := strings.TrimSuffix(in.Name, ".in")
+				if name != strings.TrimSuffix(out.Name, ".out") {
+					t.Fatalf("mismatched file pair: %s and %s", in.Name, out.Name)
+				}
+				t.Run(name, func(t *testing.T) {
+					newBody, fixed := f.Fix(string(in.Data))
+					if fixed != (newBody != "") {
+						t.Fatalf("Fix() = %q, %v (len(newBody)=%d but fixed=%v)", newBody, fixed, len(newBody), fixed)
+					}
+					if newBody != string(out.Data) {
+						t.Fatalf("Fix: incorrect output:\n%s", string(diff.Diff("want", []byte(out.Data), "have", []byte(newBody))))
+					}
+				})
+			}
+		})
+	}
+}
+
+func TestPanics(t *testing.T) {
+	callRecover := func() { recover() }
+
+	func() {
+		defer callRecover()
+		var f Fixer
+		f.EnableEdits()
+		t.Errorf("EnableEdits on zero Fixer did not panic")
+	}()
+
+	func() {
+		defer callRecover()
+		var f Fixer
+		f.EnableProject("abc/xyz")
+		t.Errorf("EnableProject on zero Fixer did not panic")
+	}()
+
+	func() {
+		defer callRecover()
+		var f Fixer
+		f.Run()
+		t.Errorf("Run on zero Fixer did not panic")
+	}()
+}
+
+func TestErrors(t *testing.T) {
+	var f Fixer
+	if err := f.AutoLink(`\`, ""); err == nil {
+		t.Fatalf("AutoLink succeeded on bad regexp")
+	}
+	if err := f.ReplaceText(`\`, ""); err == nil {
+		t.Fatalf("ReplaceText succeeded on bad regexp")
+	}
+	if err := f.ReplaceURL(`\`, ""); err == nil {
+		t.Fatalf("ReplaceText succeeded on bad regexp")
+	}
+}
+
+func TestGitHub(t *testing.T) {
+	testGH := func() *github.Client {
+		db := storage.MemDB()
+		gh := github.New(testutil.Slogger(t), db, nil, nil)
+		gh.Testing().AddIssue("rsc/tmp", &github.Issue{
+			Number:    18,
+			Title:     "spellchecking",
+			Body:      "Contexts are cancelled.",
+			CreatedAt: "2024-06-17T20:16:49-04:00",
+			UpdatedAt: "2024-06-17T20:16:49-04:00",
+		})
+		gh.Testing().AddIssue("rsc/tmp", &github.Issue{
+			Number:      19,
+			Title:       "spellchecking",
+			Body:        "Contexts are cancelled.",
+			CreatedAt:   "2024-06-17T20:16:49-04:00",
+			UpdatedAt:   "2024-06-17T20:16:49-04:00",
+			PullRequest: new(struct{}),
+		})
+
+		gh.Testing().AddIssueComment("rsc/tmp", 18, &github.IssueComment{
+			Body:      "No really, contexts are cancelled.",
+			CreatedAt: "2024-06-17T20:16:49-04:00",
+			UpdatedAt: "2024-06-17T20:16:49-04:00",
+		})
+
+		gh.Testing().AddIssueComment("rsc/tmp", 18, &github.IssueComment{
+			Body:      "Completely unrelated.",
+			CreatedAt: "2024-06-17T20:16:49-04:00",
+			UpdatedAt: "2024-06-17T20:16:49-04:00",
+		})
+
+		return gh
+	}
+
+	// Check for comment with too-new cutoff and edits disabled.
+	// Finds nothing but also no-op.
+	gh := testGH()
+	lg, buf := testutil.SlogBuffer()
+	f := New(lg, gh, "fixer1")
+	f.SetStderr(testutil.LogWriter(t))
+	f.EnableProject("rsc/tmp")
+	f.SetTimeLimit(time.Date(2222, 1, 1, 1, 1, 1, 1, time.UTC))
+	f.ReplaceText("cancelled", "canceled")
+	f.Run()
+	// t.Logf("output:\n%s", buf)
+	if bytes.Contains(buf.Bytes(), []byte("commentfix rewrite")) {
+		t.Fatalf("logs mention rewrite of old comment:\n%s", buf.Bytes())
+	}
+
+	// Check again with old enough cutoff.
+	// Finds comment but does not edit, does not advance cursor.
+	f = New(lg, gh, "fixer1")
+	f.SetStderr(testutil.LogWriter(t))
+	f.EnableProject("rsc/tmp")
+	f.SetTimeLimit(time.Time{})
+	f.ReplaceText("cancelled", "canceled")
+	f.Run()
+	// t.Logf("output:\n%s", buf)
+	if !bytes.Contains(buf.Bytes(), []byte("commentfix rewrite")) {
+		t.Fatalf("logs do not mention rewrite of comment:\n%s", buf.Bytes())
+	}
+	if bytes.Contains(buf.Bytes(), []byte("editing github")) {
+		t.Fatalf("logs incorrectly mention editing github:\n%s", buf.Bytes())
+	}
+
+	// Run with too-new cutoff and edits enabled, should make issue not seen again.
+	buf.Truncate(0)
+	f.SetTimeLimit(time.Date(2222, 1, 1, 1, 1, 1, 1, time.UTC))
+	f.EnableEdits()
+	f.Run()
+	// t.Logf("output:\n%s", buf)
+	if bytes.Contains(buf.Bytes(), []byte("commentfix rewrite")) {
+		t.Fatalf("logs incorrectly mention rewrite of comment:\n%s", buf.Bytes())
+	}
+
+	f.SetTimeLimit(time.Time{})
+	f.Run()
+	// t.Logf("output:\n%s", buf)
+	if bytes.Contains(buf.Bytes(), []byte("commentfix rewrite")) {
+		t.Fatalf("logs incorrectly mention rewrite of comment:\n%s", buf.Bytes())
+	}
+
+	// Write comment (now using fixer2 to avoid 'marked as old' in fixer1).
+	lg, buf = testutil.SlogBuffer()
+	f = New(lg, gh, "fixer2")
+	f.SetStderr(testutil.LogWriter(t))
+	f.EnableProject("rsc/tmp")
+	f.ReplaceText("cancelled", "canceled")
+	f.SetTimeLimit(time.Time{})
+	f.EnableEdits()
+	f.Run()
+	// t.Logf("output:\n%s", buf)
+	if !bytes.Contains(buf.Bytes(), []byte("commentfix rewrite")) {
+		t.Fatalf("logs do not mention rewrite of comment:\n%s", buf.Bytes())
+	}
+	if !bytes.Contains(buf.Bytes(), []byte("editing github")) {
+		t.Fatalf("logs do not mention editing github:\n%s", buf.Bytes())
+	}
+	if !bytes.Contains(buf.Bytes(), []byte(`editing github" url=https://api.github.com/repos/rsc/tmp/issues/18`)) {
+		t.Fatalf("logs do not mention editing issue body:\n%s", buf.Bytes())
+	}
+	if bytes.Contains(buf.Bytes(), []byte(`editing github" url=https://api.github.com/repos/rsc/tmp/issues/19`)) {
+		t.Fatalf("logs incorrectly mention editing pull request body:\n%s", buf.Bytes())
+	}
+	if !bytes.Contains(buf.Bytes(), []byte(`editing github" url=https://api.github.com/repos/rsc/tmp/issues/comments/10000000001`)) {
+		t.Fatalf("logs do not mention editing issue comment:\n%s", buf.Bytes())
+	}
+	if bytes.Contains(buf.Bytes(), []byte("ERROR")) {
+		t.Fatalf("editing failed:\n%s", buf.Bytes())
+	}
+
+	// Try again; comment should now be marked old in watcher.
+	lg, buf = testutil.SlogBuffer()
+	f = New(lg, gh, "fixer2")
+	f.SetStderr(testutil.LogWriter(t))
+	f.EnableProject("rsc/tmp")
+	f.ReplaceText("cancelled", "canceled")
+	f.EnableEdits()
+	f.SetTimeLimit(time.Time{})
+	f.Run()
+	// t.Logf("output:\n%s", buf)
+	if bytes.Contains(buf.Bytes(), []byte("commentfix rewrite")) {
+		t.Fatalf("logs incorrectly mention rewrite of comment:\n%s", buf.Bytes())
+	}
+
+	// Check that not enabling the project doesn't edit comments.
+	lg, buf = testutil.SlogBuffer()
+	f = New(lg, gh, "fixer3")
+	f.SetStderr(testutil.LogWriter(t))
+	f.EnableProject("xyz/tmp")
+	f.ReplaceText("cancelled", "canceled")
+	f.EnableEdits()
+	f.SetTimeLimit(time.Time{})
+	f.Run()
+	// t.Logf("output:\n%s", buf)
+	if bytes.Contains(buf.Bytes(), []byte("commentfix rewrite")) {
+		t.Fatalf("logs incorrectly mention rewrite of comment:\n%s", buf.Bytes())
+	}
+}
diff --git a/internal/commentfix/testdata/autolink.txt b/internal/commentfix/testdata/autolink.txt
new file mode 100644
index 0000000..054699e
--- /dev/null
+++ b/internal/commentfix/testdata/autolink.txt
@@ -0,0 +1,12 @@
+{{.AutoLink `\bCL (\d+)\b` "https://go.dev/cl/$1"}}
+-- 1.in --
+This is in CL 12345.
+-- 1.out --
+This is in [CL 12345](https://go.dev/cl/12345).
+-- 2.in --
+This is in **CL 12345**.
+-- 2.out --
+This is in **[CL 12345](https://go.dev/cl/12345)**.
+-- 3.in --
+This is in [the CL 12345 page](https://go.dev/cl/12345).
+-- 3.out --
diff --git a/internal/commentfix/testdata/nop.txt b/internal/commentfix/testdata/nop.txt
new file mode 100644
index 0000000..eabb4de
--- /dev/null
+++ b/internal/commentfix/testdata/nop.txt
@@ -0,0 +1,12 @@
+{{/*
+  make sure this does not loop;
+  it claims to have edited (and did edit) the text,
+  so the result is non-empty,
+  but no actual change is made.
+*/}}
+{{.ReplaceText `cancelled` "canceled"}}
+{{.ReplaceText `canceled` "cancelled"}}
+-- 1.in --
+The context is cancelled.
+-- 1.out --
+The context is cancelled.
diff --git a/internal/commentfix/testdata/order.txt b/internal/commentfix/testdata/order.txt
new file mode 100644
index 0000000..e0486a0
--- /dev/null
+++ b/internal/commentfix/testdata/order.txt
@@ -0,0 +1,17 @@
+{{/*
+  rules apply in order.
+  make sure this does not loop;
+  it claims to have edited (and did edit) the text,
+  so the result is non-empty,
+  but no actual change is made.
+*/}}
+{{.ReplaceText `cancelled` "canceled"}}
+{{.ReplaceText `canceled` "cancelled"}}
+-- 1.in --
+The context is cancelled.
+-- 1.out --
+The context is cancelled.
+-- 2.in --
+The context is canceled.
+-- 2.out --
+The context is cancelled.
diff --git a/internal/commentfix/testdata/replacetext.txt b/internal/commentfix/testdata/replacetext.txt
new file mode 100644
index 0000000..06569dd
--- /dev/null
+++ b/internal/commentfix/testdata/replacetext.txt
@@ -0,0 +1,35 @@
+{{.ReplaceText `cancelled` "canceled"}}
+-- 1.in --
+The context is cancelled.
+-- 1.out --
+The context is canceled.
+-- 2.in --
+	fmt.Printf("cancelled\n")
+-- 2.out --
+-- 3.in --
+The context **is cancelled.**
+-- 3.out --
+The context **is canceled.**
+-- 4.in --
+The context *is cancelled.*
+-- 4.out --
+The context *is canceled.*
+-- 4.in --
+The context ~~is cancelled.~~
+-- 4.out --
+The context ~~is canceled.~~
+-- 5.in --
+# Contexts that are cancelled
+-- 5.out --
+# Contexts that are canceled
+-- 6.in --
+Here is a list of misspelled words:
+ - cancelled
+-- 6.out --
+Here is a list of misspelled words:
+
+  - canceled
+-- 7.in --
+> The context is cancelled.
+-- 7.out --
+> The context is canceled.
diff --git a/internal/commentfix/testdata/replaceurl.txt b/internal/commentfix/testdata/replaceurl.txt
new file mode 100644
index 0000000..fe00224
--- /dev/null
+++ b/internal/commentfix/testdata/replaceurl.txt
@@ -0,0 +1,25 @@
+{{.ReplaceURL `https://golang.org/(.*)` "https://go.dev/$1#"}}
+{{.ReplaceURL `(?i)https://lowercase.com/(.*)` "https://lowercase.com/$1"}}
+-- 1.in --
+Visit https://golang.org/doc for more docs.
+-- 1.out --
+Visit [https://go.dev/doc#](https://go.dev/doc#) for more docs.
+-- 2.in --
+Visit <https://golang.org/doc> for more docs.
+-- 2.out --
+Visit <https://go.dev/doc#> for more docs.
+-- 3.in --
+Visit [this page](https://golang.org/doc) for more docs.
+-- 3.out --
+Visit [this page](https://go.dev/doc#) for more docs.
+-- 4.in --
+Visit [https://golang.org/doc](https://golang.org/doc) for more docs.
+-- 4.out --
+Visit [https://go.dev/doc#](https://go.dev/doc#) for more docs.
+-- 5.in --
+Visit <https://LOWERcaSE.cOM/doc> for more docs.
+-- 5.out --
+Visit <https://lowercase.com/doc> for more docs.
+-- 6.in --
+Visit <https://lowercase.com/doc> for more docs.
+-- 6.out --