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