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 _ "embed"
12 "fmt"
13 "io"
14 "os"
15 "path/filepath"
16 "regexp"
17 "strings"
18
19 "github.com/tidwall/gjson"
20 gitroot "gitroot.dev/libs/golang/plugin"
21)
22
23type Plugin struct {
24 server gitroot.Server
25 conf pluginConf
26 newGraft *graft
27}
28
29type pluginConf struct {
30 defaultTargetBranch string
31}
32
33type graft struct {
34 isCreation bool
35 filename string
36 branch string
37 commitsLines []string
38 pushContent []string
39 content string
40 isCommitReview bool
41 isPostCommitReview bool
42 currentHash string
43}
44
45var defaultRun = []gitroot.PluginRun{{
46 Path: "**/*",
47 Branch: []string{"*"},
48 When: gitroot.PluginRunWhenAll,
49 Write: gitroot.PluginWrite{
50 Git: []gitroot.PluginWriteRight{{
51 Path: "**/*",
52 Can: gitroot.PluginWriteRightCanAll,
53 }},
54 Web: []gitroot.PluginWriteRight{},
55 },
56 Configuration: map[string]any{"defaultTargetBranch": "main"},
57}}
58
59var defaultGraft = `---
60target: %s
61status: draft
62---
63
64# Want to merge %s
65
66Please add description of your work here.
67
68When your changes are ready to be reviewed, add at the end of this file a comment with:
69
70` + strings.Repeat("`", 3) + `markdown
71---
72
73/review
74` + strings.Repeat("`", 3) + "\n"
75
76var defaultReviewMessage = `
77---
78
79Please review changes with:
80
81` + strings.Repeat("`", 3) + `shell
82git fetch origin %s
83git checkout %s
84git diff origin/HEAD..HEAD
85` + strings.Repeat("`", 3) + `
86
87Make 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.
88`
89
90var targetRegexp = regexp.MustCompile(`- target:\s(.*)`)
91
92func (p *Plugin) Init(repoName string, confHasChanged bool, serializedConf string) error {
93 defaultTargetBranch := gjson.Parse(serializedConf).Get("defaultTargetBranch").String()
94 if defaultTargetBranch == "" {
95 defaultTargetBranch = "main"
96 }
97 p.conf = pluginConf{
98 defaultTargetBranch: defaultTargetBranch,
99 }
100 p.newGraft = nil
101 return nil
102}
103
104func (p *Plugin) StartCommit(commit gitroot.Commit) error {
105 //does nothing if user push on main
106 if commit.Branch == p.conf.defaultTargetBranch {
107 return nil
108 }
109 commitLine := fmt.Sprintf("### %s\n%s\n", commit.Message, commit.Hash)
110 if p.newGraft == nil {
111 graftFilepath := fmt.Sprintf("grafts/%s.md", commit.Branch)
112 file, err := p.server.Worktree().Open(graftFilepath)
113 if err != nil && !os.IsNotExist(err) {
114 return err
115 }
116 if file == nil {
117 p.newGraft = &graft{
118 isCreation: true,
119 filename: graftFilepath,
120 branch: commit.Branch,
121 commitsLines: []string{commitLine},
122 content: fmt.Sprintf(defaultGraft, p.conf.defaultTargetBranch, commit.Branch),
123 }
124 } else {
125 filecontent, err := io.ReadAll(file)
126 if err != nil {
127 return err
128 }
129 p.newGraft = &graft{
130 isCreation: false,
131 filename: graftFilepath,
132 branch: commit.Branch,
133 commitsLines: []string{commitLine},
134 content: string(filecontent),
135 }
136 }
137 } else {
138 p.newGraft.commitsLines = append(p.newGraft.commitsLines, commitLine)
139 }
140 p.newGraft.pushContent = []string{}
141 reviews := strings.Split(p.newGraft.content, "/review")
142 somethingAfterUserReview := len(reviews) >= 3 && strings.Contains(reviews[len(reviews)-1], "---")
143 p.newGraft.isCommitReview = len(reviews) >= 3 && !somethingAfterUserReview
144 p.newGraft.isPostCommitReview = strings.Contains(p.newGraft.content, "status: review")
145 p.newGraft.currentHash = commit.Hash
146 return nil
147}
148
149func (p *Plugin) AddFile(path string) error {
150 //does nothing if user push on main
151 if p.newGraft == nil {
152 return nil
153 }
154 if path != p.newGraft.filename {
155 from, _ := filepath.Abs(path)
156 relPath, _ := filepath.Rel("/grafts/", from)
157 p.newGraft.pushContent = append(p.newGraft.pushContent, fmt.Sprintf("- ++ [%s](%s)", path, filepath.Clean(relPath)))
158 }
159 return nil
160}
161
162func (p *Plugin) ModFile(fromPath string, toPath string) error {
163 //does nothing if user push on main
164 if p.newGraft == nil {
165 return nil
166 }
167
168 if toPath != p.newGraft.filename {
169 from, _ := filepath.Abs(toPath)
170 relPath, _ := filepath.Rel("/grafts/", from)
171 if fromPath != toPath {
172 p.newGraft.pushContent = append(p.newGraft.pushContent, fmt.Sprintf("- %s -> [%s](%s)", fromPath, toPath, filepath.Clean(relPath)))
173 }
174 p.newGraft.pushContent = append(p.newGraft.pushContent, fmt.Sprintf("- +- [%s](%s)", toPath, filepath.Clean(relPath)))
175 if p.newGraft.isPostCommitReview {
176 if diff, err := p.server.DiffWithParent(toPath, p.newGraft.currentHash); err == nil {
177 p.newGraft.pushContent = append(p.newGraft.pushContent, diff)
178 } else {
179 p.server.LogError("can't get diff", err)
180 }
181 }
182 }
183 return nil
184}
185
186func (p *Plugin) DelFile(path string) error {
187 //does nothing if user push on main
188 if p.newGraft == nil {
189 return nil
190 }
191 if path != p.newGraft.filename {
192 from, _ := filepath.Abs(path)
193 relPath, _ := filepath.Rel("/grafts/", from)
194 p.newGraft.pushContent = append(p.newGraft.pushContent, fmt.Sprintf("- -- [%s](%s)", path, filepath.Clean(relPath)))
195 }
196 return nil
197}
198
199func (p *Plugin) EndCommit(commit gitroot.Commit) error {
200 //does nothing if user push on main
201 if p.newGraft == nil {
202 return nil
203 }
204 if len(p.newGraft.pushContent) > 0 {
205 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"))
206 } else {
207 // a commit which touch others files or only graft file is ignored
208 p.newGraft.commitsLines = p.newGraft.commitsLines[:len(p.newGraft.commitsLines)-1]
209 }
210 return nil
211}
212
213func (p *Plugin) Finish() error {
214 // does nothing if user push on main
215 if p.newGraft == nil {
216 return nil
217 }
218 if len(p.newGraft.commitsLines) > 0 {
219 commit := "commit"
220 if len(p.newGraft.commitsLines) > 1 {
221 commit = "commits"
222 }
223 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"))
224 p.server.ModifyContent(p.newGraft.filename, content)
225 }
226 if p.newGraft.isCommitReview {
227 content := strings.Replace(p.newGraft.content, "status: draft", "status: review\nreviewers: []", 1)
228 content = content + fmt.Sprintf(defaultReviewMessage, p.newGraft.branch, p.newGraft.branch)
229 p.server.ModifyContent(p.newGraft.filename, content)
230 }
231 needMerge := strings.Contains(p.newGraft.content, "/merge")
232 if needMerge {
233 content := strings.Replace(p.newGraft.content, "status: review", "status: done", 1)
234 p.server.ModifyContent(p.newGraft.filename, content)
235 }
236 message := "graft updated"
237 if p.newGraft.isCreation {
238 message = "graft created"
239 }
240 p.server.CommitAllIfNeeded(message)
241 if needMerge {
242 all := targetRegexp.FindStringSubmatch(p.newGraft.content)
243 if len(all) > 1 {
244 p.server.Merge(all[1], p.newGraft.branch)
245 } else {
246 p.server.Merge(p.conf.defaultTargetBranch, p.newGraft.branch)
247 }
248 }
249 return nil
250}
251
252func Build(server gitroot.Server) gitroot.Plugin {
253 return &Plugin{
254 server: server,
255 }
256}
257
258//go:wasmexport install
259func main() {
260 gitroot.Register(defaultRun, Build)
261}