GitRoot

craft your forge, build your project, grow your community freely
  1// SPDX-FileCopyrightText: 2025 Romain Maneschi <romain@gitroot.dev>
  2//
  3// SPDX-License-Identifier: EUPL-1.2
  4
  5package main
  6
  7// compile cmd
  8// tinygo build -o grafter-0.0.1.wasm -scheduler=none --no-debug -target=wasi ./
  9
 10import (
 11	"bytes"
 12	_ "embed"
 13	"fmt"
 14	"io"
 15	"os"
 16	"path/filepath"
 17	"regexp"
 18	"strings"
 19
 20	"github.com/tidwall/gjson"
 21	gitroot "gitroot.dev/libs/golang/plugin"
 22	"gitroot.dev/libs/golang/plugin/model"
 23)
 24
 25type Plugin struct {
 26	server   gitroot.Server
 27	conf     pluginConf
 28	newGraft *graft
 29}
 30
 31type pluginConf struct {
 32	defaultTargetBranch string
 33}
 34
 35const (
 36	statusNew    = ""
 37	statusDraft  = "draft"
 38	statusReview = "review"
 39	statusMerge  = "merge"
 40)
 41
 42type graft struct {
 43	status       string
 44	filename     string
 45	branch       string
 46	commitsLines []string
 47	pushContent  []string
 48	content      string
 49	currentHash  string
 50}
 51
 52var defaultRun = []model.PluginRun{{
 53	Path:   "**/*",
 54	Branch: []string{"*"},
 55	When:   model.PluginRunWhenAll,
 56	Write: model.PluginWrite{
 57		Git: []model.PluginWriteRight{{
 58			Path: "**/*",
 59			Can:  model.PluginWriteRightCanAll,
 60		}},
 61		Web: []model.PluginWriteRight{},
 62	},
 63	Configuration: map[string]any{"defaultTargetBranch": "main"},
 64}}
 65
 66var defaultGraft = `---
 67target: %s
 68status: draft
 69---
 70
 71# Want to merge %s
 72
 73Please add description of your work here.
 74
 75When your changes are ready to be reviewed, change the status from 'status: draft' to 'status: review'.
 76`
 77
 78var defaultReviewMessage = `
 79---
 80
 81Please review changes with:
 82
 83` + strings.Repeat("`", 3) + `shell
 84git fetch origin %s
 85git checkout %s
 86git diff origin/HEAD..HEAD
 87` + strings.Repeat("`", 3) + `
 88
 89Make your changes about the code inside the code directly (remarks, questions can be made in the form of comments). General remarks on the why can be made here. When done commit and push directly on this branch. Graft plugin will make the diff and display it here.
 90
 91When then changes are ready to be merged, change the status from 'status: review' to 'status: merge'.
 92`
 93
 94var targetRegexp = regexp.MustCompile(`- target:\s(.*)`)
 95
 96func (p *Plugin) Init(repoName string, confHasChanged bool, serializedConf string) error {
 97	defaultTargetBranch := gjson.Parse(serializedConf).Get("defaultTargetBranch").String()
 98	if defaultTargetBranch == "" {
 99		defaultTargetBranch = "main"
100	}
101	p.conf = pluginConf{
102		defaultTargetBranch: defaultTargetBranch,
103	}
104	p.newGraft = nil
105	return nil
106}
107
108func (p *Plugin) StartCommit(commit model.Commit) error {
109	//does nothing if user push on main
110	if commit.Branch == p.conf.defaultTargetBranch {
111		return nil
112	}
113	commitLine := fmt.Sprintf("### %s (%s)\n", commit.Message, commit.Hash)
114	if p.newGraft == nil {
115		graftFilepath := fmt.Sprintf("grafts/%s.md", commit.Branch)
116		file, err := p.server.Worktree().Open(graftFilepath)
117		if err != nil && !os.IsNotExist(err) {
118			return err
119		}
120		if file == nil {
121			p.newGraft = &graft{
122				status:       statusNew,
123				filename:     graftFilepath,
124				branch:       commit.Branch,
125				commitsLines: []string{commitLine},
126				content:      fmt.Sprintf(defaultGraft, p.conf.defaultTargetBranch, commit.Branch),
127			}
128		} else {
129			filecontent, err := io.ReadAll(file)
130			if err != nil {
131				return err
132			}
133			metas, needMeta := p.parseMetas(filecontent)
134			if needMeta {
135				if bytes.HasPrefix(filecontent, []byte("---\n")) {
136					filecontent = bytes.TrimPrefix(filecontent, []byte("---\n"))
137					filecontent = filecontent[bytes.Index(filecontent, []byte("---\n"))+4:]
138				}
139				filecontent = append(p.serializeMetas(metas), bytes.TrimPrefix(filecontent, []byte("\n"))...)
140			}
141			p.newGraft = &graft{
142				status:       metas["status"],
143				filename:     graftFilepath,
144				branch:       commit.Branch,
145				commitsLines: []string{commitLine},
146				content:      string(filecontent),
147			}
148
149			formattedReviewMessage := fmt.Sprintf(defaultReviewMessage, commit.Branch, commit.Branch)
150			if metas["status"] == statusReview && !strings.Contains(p.newGraft.content, formattedReviewMessage) {
151				p.newGraft.content = fmt.Sprintf("%s%s", p.newGraft.content, formattedReviewMessage)
152			}
153		}
154	} else {
155		p.newGraft.commitsLines = append(p.newGraft.commitsLines, commitLine)
156	}
157	p.newGraft.pushContent = []string{}
158	p.newGraft.currentHash = commit.Hash
159	return nil
160}
161
162func (p *Plugin) serializeMetas(metas map[string]string) []byte {
163	m := strings.Builder{}
164	m.WriteString("---\n")
165	if target, ok := metas["target"]; ok {
166		m.WriteString(fmt.Sprintf("target: %s\n", target))
167	}
168	if status, ok := metas["status"]; ok {
169		m.WriteString(fmt.Sprintf("status: %s\n", status))
170	}
171	if reviewers, ok := metas["reviewers"]; ok {
172		m.WriteString(fmt.Sprintf("reviewers: %s\n", reviewers))
173	}
174	m.WriteString("---\n\n")
175	return []byte(m.String())
176}
177
178func (p *Plugin) parseMetas(filecontent []byte) (map[string]string, bool) {
179	metas := map[string]string{}
180	fileSplited := strings.SplitN(string(filecontent), "---", 3)
181	needMeta := false
182	if len(fileSplited) > 2 {
183		metasStr := strings.Split(fileSplited[1], "\n")
184		for _, m := range metasStr {
185			keyVal := strings.Split(m, ":")
186			if len(keyVal) > 1 {
187				metas[strings.TrimSpace(keyVal[0])] = strings.TrimSpace(keyVal[1])
188			}
189		}
190	}
191	if _, ok := metas["status"]; !ok {
192		needMeta = true
193		metas["status"] = statusDraft
194	}
195	if _, ok := metas["target"]; !ok {
196		needMeta = true
197		metas["target"] = p.conf.defaultTargetBranch
198	}
199	if _, ok := metas["reviewers"]; !ok && metas["status"] == statusReview {
200		needMeta = true
201		metas["reviewers"] = "[]"
202	}
203	return metas, needMeta
204}
205
206func (p *Plugin) AddFile(path string) error {
207	//does nothing if user push on main
208	if p.newGraft == nil {
209		return nil
210	}
211	if path != p.newGraft.filename {
212		from, _ := filepath.Abs(path)
213		relPath, _ := filepath.Rel("/grafts/", from)
214		p.newGraft.pushContent = append(p.newGraft.pushContent, fmt.Sprintf("- ++ [%s](%s)", path, filepath.Clean(relPath)))
215	}
216	return nil
217}
218
219func (p *Plugin) ModFile(fromPath string, toPath string) error {
220	//does nothing if user push on main
221	if p.newGraft == nil {
222		return nil
223	}
224
225	if toPath != p.newGraft.filename {
226		from, _ := filepath.Abs(toPath)
227		relPath, _ := filepath.Rel("/grafts/", from)
228		if fromPath != toPath {
229			p.newGraft.pushContent = append(p.newGraft.pushContent, fmt.Sprintf("- %s -> [%s](%s)", fromPath, toPath, filepath.Clean(relPath)))
230		}
231		p.newGraft.pushContent = append(p.newGraft.pushContent, fmt.Sprintf("- +- [%s](%s)", toPath, filepath.Clean(relPath)))
232		if p.newGraft.status == statusReview {
233			if diff, err := p.server.DiffWithParent(p.newGraft.currentHash, fromPath, toPath); err == nil {
234				p.newGraft.pushContent = append(p.newGraft.pushContent, diff)
235			} else {
236				p.server.LogError("can't get diff", err)
237			}
238		}
239	}
240	return nil
241}
242
243func (p *Plugin) DelFile(path string) error {
244	//does nothing if user push on main
245	if p.newGraft == nil {
246		return nil
247	}
248	if path != p.newGraft.filename {
249		from, _ := filepath.Abs(path)
250		relPath, _ := filepath.Rel("/grafts/", from)
251		p.newGraft.pushContent = append(p.newGraft.pushContent, fmt.Sprintf("- -- [%s](%s)", path, filepath.Clean(relPath)))
252	}
253	return nil
254}
255
256func (p *Plugin) EndCommit(commit model.Commit) error {
257	//does nothing if user push on main
258	if p.newGraft == nil {
259		return nil
260	}
261	if len(p.newGraft.pushContent) > 0 {
262		p.newGraft.commitsLines[len(p.newGraft.commitsLines)-1] = fmt.Sprintf("%s\n%s\n", p.newGraft.commitsLines[len(p.newGraft.commitsLines)-1], strings.Join(p.newGraft.pushContent, "\n"))
263	} else {
264		// a commit which touch others files or only graft file is ignored
265		p.newGraft.commitsLines = p.newGraft.commitsLines[:len(p.newGraft.commitsLines)-1]
266	}
267	return nil
268}
269
270func (p *Plugin) Finish() error {
271	// does nothing if user push on main
272	if p.newGraft == nil {
273		return nil
274	}
275	content := p.newGraft.content
276	if len(p.newGraft.commitsLines) > 0 {
277		commit := "commit"
278		if len(p.newGraft.commitsLines) > 1 {
279			commit = "commits"
280		}
281		content = fmt.Sprintf("%s\n---\n\n## Push %d %s\n\n%s", p.newGraft.content, len(p.newGraft.commitsLines), commit, strings.Join(p.newGraft.commitsLines, "\n"))
282	}
283	p.server.ModifyContent(p.newGraft.filename, content)
284	message := "graft updated"
285	if p.newGraft.status == statusNew {
286		message = "graft created"
287	}
288	p.server.CommitAllIfNeeded(message)
289	if p.newGraft.status == statusMerge {
290		all := targetRegexp.FindStringSubmatch(p.newGraft.content)
291		if len(all) > 1 {
292			p.server.Merge(all[1], p.newGraft.branch)
293		} else {
294			p.server.Merge(p.conf.defaultTargetBranch, p.newGraft.branch)
295		}
296	}
297	return nil
298}
299
300func Build(server gitroot.Server) gitroot.Plugin {
301	return &Plugin{
302		server: server,
303	}
304}
305
306//go:wasmexport install
307func main() {
308	gitroot.Register(defaultRun, Build)
309}