Compare commits

...

9 Commits

Author SHA1 Message Date
bytedream
6273407a6f
Merge e5ea1e8ae3 into e5e40787dc 2024-08-31 22:59:29 +02:00
silverwind
e5e40787dc
Move web globals to web_src/js/globals.d.ts (#31943)
This file serves exclusively to support `web_src/js`, so move it there.
2024-08-30 07:36:53 +00:00
GiteaBot
1f924d845e [skip ci] Updated translations via Crowdin 2024-08-30 00:29:03 +00:00
silverwind
f46e7eae93
Update JS and PY dependencies (#31940)
- `stylelint` is excluded because of deprecation warnings added in
16.8.2, depending on
https://github.com/AndyOGo/stylelint-declaration-strict-value/issues/379
- `eslint-plugin-sonarjs@2` adds a lot of new rules, but I have not
gotten around to reviewing them yet, so rule config is unchanged.
- Fixes https://github.com/go-gitea/gitea/security/dependabot/70
- Tested code editor and contributor graph
2024-08-29 14:51:51 +00:00
Lunny Xiao
06c67c6d5a
Fix search team (#31923)
Fix #20658
2024-08-29 13:26:25 +00:00
Lunny Xiao
fcac28c878
Upgrade micromatch to 4.0.8 (#31939) 2024-08-29 04:54:38 +00:00
Jason Song
bc0977f1c9
Refactor globallock (#31933)
Follow #31908. The main refactor is that it has removed the returned
context of `Lock`.

The returned context of `Lock` in old code is to provide a way to let
callers know that they have lost the lock. But in most cases, callers
shouldn't cancel what they are doing even it has lost the lock. And the
design would confuse developers and make them use it incorrectly.

See the discussion history:
https://github.com/go-gitea/gitea/pull/31813#discussion_r1732041513 and
https://github.com/go-gitea/gitea/pull/31813#discussion_r1734078998

It's a breaking change, but since the new module hasn't been used yet, I
think it's OK to not add the `pr/breaking` label.

## Design principles

It's almost copied from #31908, but with some changes.

### Use spinlock even in memory implementation (unchanged)

In actual use cases, users may cancel requests. `sync.Mutex` will block
the goroutine until the lock is acquired even if the request is
canceled. And the spinlock is more suitable for this scenario since it's
possible to give up the lock acquisition.

Although the spinlock consumes more CPU resources, I think it's
acceptable in most cases.

### Do not expose the mutex to callers (unchanged)

If we expose the mutex to callers, it's possible for callers to reuse
the mutex, which causes more complexity.

For example:
```go
lock := GetLocker(key)
lock.Lock()
// ...
// even if the lock is unlocked, we cannot GC the lock,
// since the caller may still use it again.
lock.Unlock()
lock.Lock()
// ...
lock.Unlock()

// callers have to GC the lock manually.
RemoveLocker(key)
```

That's why
https://github.com/go-gitea/gitea/pull/31813#discussion_r1721200549

In this PR, we only expose `ReleaseFunc` to callers. So callers just
need to call `ReleaseFunc` to release the lock, and do not need to care
about the lock's lifecycle.
```go
release, err := locker.Lock(ctx, key)
if err != nil {
    return err
}
// ...
release()

// if callers want to lock again, they have to re-acquire the lock.
release, err := locker.Lock(ctx, key)
// ...
```

In this way, it's also much easier for redis implementation to extend
the mutex automatically, so that callers do not need to care about the
lock's lifecycle. See also
https://github.com/go-gitea/gitea/pull/31813#discussion_r1722659743

### Use "release" instead of "unlock" (unchanged)

For "unlock", it has the meaning of "unlock an acquired lock". So it's
not acceptable to call "unlock" when failed to acquire the lock, or call
"unlock" multiple times. It causes more complexity for callers to decide
whether to call "unlock" or not.

So we use "release" instead of "unlock" to make it clear. Whether the
lock is acquired or not, callers can always call "release", and it's
also safe to call "release" multiple times.

But the code DO NOT expect callers to not call "release" after acquiring
the lock. If callers forget to call "release", it will cause resource
leak. That's why it's always safe to call "release" without extra
checks: to avoid callers to forget to call it.

### Acquired locks could be lost, but the callers shouldn't stop

Unlike `sync.Mutex` which will be locked forever once acquired until
calling `Unlock`, for distributed lock, the acquired lock could be lost.

For example, the caller has acquired the lock, and it holds the lock for
a long time since auto-extending is working for redis. However, it lost
the connection to the redis server, and it's impossible to extend the
lock anymore.

In #31908, it will cancel the context to make the operation stop, but
it's not safe. Many operations are not revert-able. If they have been
interrupted, then the instance goes corrupted. So `Lock` won't return
`ctx` anymore in this PR.

### Multiple ways to use the lock

1. Regular way

```go
release, err := Lock(ctx, key)
if err != nil {
    return err
}
defer release()
// ...
```

2. Early release

```go
release, err := Lock(ctx, key)
if err != nil {
    return err
}
defer release()
// ...
// release the lock earlier
release()
// continue to do something else
// ...
```

3. Functional way

```go
if err := LockAndDo(ctx, key, func(ctx context.Context) error {
    // ...
    return nil
}); err != nil {
    return err
}
```
2024-08-29 03:48:21 +00:00
silverwind
7207d93f01
Fix a number of Typescript issues (#31877)
Typescript error count is reduced from 633 to 540 with this. No runtime
changes except in test code.
2024-08-28 18:32:38 +02:00
bytedream
e5ea1e8ae3 Auto-expand running actions step 2024-03-25 02:41:39 +01:00
30 changed files with 3972 additions and 683 deletions

View File

@ -27,20 +27,20 @@ func DefaultLocker() Locker {
// Lock tries to acquire a lock for the given key, it uses the default locker.
// Read the documentation of Locker.Lock for more information about the behavior.
func Lock(ctx context.Context, key string) (context.Context, ReleaseFunc, error) {
func Lock(ctx context.Context, key string) (ReleaseFunc, error) {
return DefaultLocker().Lock(ctx, key)
}
// TryLock tries to acquire a lock for the given key, it uses the default locker.
// Read the documentation of Locker.TryLock for more information about the behavior.
func TryLock(ctx context.Context, key string) (bool, context.Context, ReleaseFunc, error) {
func TryLock(ctx context.Context, key string) (bool, ReleaseFunc, error) {
return DefaultLocker().TryLock(ctx, key)
}
// LockAndDo tries to acquire a lock for the given key and then calls the given function.
// It uses the default locker, and it will return an error if failed to acquire the lock.
func LockAndDo(ctx context.Context, key string, f func(context.Context) error) error {
ctx, release, err := Lock(ctx, key)
release, err := Lock(ctx, key)
if err != nil {
return err
}
@ -52,7 +52,7 @@ func LockAndDo(ctx context.Context, key string, f func(context.Context) error) e
// TryLockAndDo tries to acquire a lock for the given key and then calls the given function.
// It uses the default locker, and it will return false if failed to acquire the lock.
func TryLockAndDo(ctx context.Context, key string, f func(context.Context) error) (bool, error) {
ok, ctx, release, err := TryLock(ctx, key)
ok, release, err := TryLock(ctx, key)
if err != nil {
return false, err
}

View File

@ -5,56 +5,34 @@ package globallock
import (
"context"
"fmt"
)
type Locker interface {
// Lock tries to acquire a lock for the given key, it blocks until the lock is acquired or the context is canceled.
//
// Lock returns a new context which should be used in the following code.
// The new context will be canceled when the lock is released or lost - yes, it's possible to lose a lock.
// For example, it lost the connection to the redis server while holding the lock.
// If it fails to acquire the lock, the returned context will be the same as the input context.
//
// Lock returns a ReleaseFunc to release the lock, it cannot be nil.
// It's always safe to call this function even if it fails to acquire the lock, and it will do nothing in that case.
// And it's also safe to call it multiple times, but it will only release the lock once.
// That's why it's called ReleaseFunc, not UnlockFunc.
// But be aware that it's not safe to not call it at all; it could lead to a memory leak.
// So a recommended pattern is to use defer to call it:
// ctx, release, err := locker.Lock(ctx, "key")
// release, err := locker.Lock(ctx, "key")
// if err != nil {
// return err
// }
// defer release()
// The ReleaseFunc will return the original context which was used to acquire the lock.
// It's useful when you want to continue to do something after releasing the lock.
// At that time, the ctx will be canceled, and you can use the returned context by the ReleaseFunc to continue:
// ctx, release, err := locker.Lock(ctx, "key")
// if err != nil {
// return err
// }
// defer release()
// doSomething(ctx)
// ctx = release()
// doSomethingElse(ctx)
// Please ignore it and use `defer release()` instead if you don't need this, to avoid forgetting to release the lock.
//
// Lock returns an error if failed to acquire the lock.
// Be aware that even the context is not canceled, it's still possible to fail to acquire the lock.
// For example, redis is down, or it reached the maximum number of tries.
Lock(ctx context.Context, key string) (context.Context, ReleaseFunc, error)
Lock(ctx context.Context, key string) (ReleaseFunc, error)
// TryLock tries to acquire a lock for the given key, it returns immediately.
// It follows the same pattern as Lock, but it doesn't block.
// And if it fails to acquire the lock because it's already locked, not other reasons like redis is down,
// it will return false without any error.
TryLock(ctx context.Context, key string) (bool, context.Context, ReleaseFunc, error)
TryLock(ctx context.Context, key string) (bool, ReleaseFunc, error)
}
// ReleaseFunc is a function that releases a lock.
// It returns the original context which was used to acquire the lock.
type ReleaseFunc func() context.Context
// ErrLockReleased is used as context cause when a lock is released
var ErrLockReleased = fmt.Errorf("lock released")
type ReleaseFunc func()

View File

@ -47,27 +47,24 @@ func TestLocker(t *testing.T) {
func testLocker(t *testing.T, locker Locker) {
t.Run("lock", func(t *testing.T) {
parentCtx := context.Background()
ctx, release, err := locker.Lock(parentCtx, "test")
release, err := locker.Lock(parentCtx, "test")
defer release()
assert.NotEqual(t, parentCtx, ctx) // new context should be returned
assert.NoError(t, err)
func() {
parentCtx, cancel := context.WithTimeout(context.Background(), time.Second)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
ctx, release, err := locker.Lock(parentCtx, "test")
release, err := locker.Lock(ctx, "test")
defer release()
assert.Error(t, err)
assert.Equal(t, parentCtx, ctx) // should return the same context
}()
release()
assert.Error(t, ctx.Err())
func() {
_, release, err := locker.Lock(context.Background(), "test")
release, err := locker.Lock(context.Background(), "test")
defer release()
assert.NoError(t, err)
@ -76,29 +73,26 @@ func testLocker(t *testing.T, locker Locker) {
t.Run("try lock", func(t *testing.T) {
parentCtx := context.Background()
ok, ctx, release, err := locker.TryLock(parentCtx, "test")
ok, release, err := locker.TryLock(parentCtx, "test")
defer release()
assert.True(t, ok)
assert.NotEqual(t, parentCtx, ctx) // new context should be returned
assert.NoError(t, err)
func() {
parentCtx, cancel := context.WithTimeout(context.Background(), time.Second)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
ok, ctx, release, err := locker.TryLock(parentCtx, "test")
ok, release, err := locker.TryLock(ctx, "test")
defer release()
assert.False(t, ok)
assert.NoError(t, err)
assert.Equal(t, parentCtx, ctx) // should return the same context
}()
release()
assert.Error(t, ctx.Err())
func() {
ok, _, release, _ := locker.TryLock(context.Background(), "test")
ok, release, _ := locker.TryLock(context.Background(), "test")
defer release()
assert.True(t, ok)
@ -107,7 +101,7 @@ func testLocker(t *testing.T, locker Locker) {
t.Run("wait and acquired", func(t *testing.T) {
ctx := context.Background()
_, release, err := locker.Lock(ctx, "test")
release, err := locker.Lock(ctx, "test")
require.NoError(t, err)
wg := &sync.WaitGroup{}
@ -115,7 +109,7 @@ func testLocker(t *testing.T, locker Locker) {
go func() {
defer wg.Done()
started := time.Now()
_, release, err := locker.Lock(context.Background(), "test") // should be blocked for seconds
release, err := locker.Lock(context.Background(), "test") // should be blocked for seconds
defer release()
assert.Greater(t, time.Since(started), time.Second)
assert.NoError(t, err)
@ -127,34 +121,15 @@ func testLocker(t *testing.T, locker Locker) {
wg.Wait()
})
t.Run("continue after release", func(t *testing.T) {
ctx := context.Background()
ctxBeforeLock := ctx
ctx, release, err := locker.Lock(ctx, "test")
require.NoError(t, err)
assert.NoError(t, ctx.Err())
assert.NotEqual(t, ctxBeforeLock, ctx)
ctxBeforeRelease := ctx
ctx = release()
assert.NoError(t, ctx.Err())
assert.Error(t, ctxBeforeRelease.Err())
// so it can continue with ctx to do more work
})
t.Run("multiple release", func(t *testing.T) {
ctx := context.Background()
_, release1, err := locker.Lock(ctx, "test")
release1, err := locker.Lock(ctx, "test")
require.NoError(t, err)
release1()
_, release2, err := locker.Lock(ctx, "test")
release2, err := locker.Lock(ctx, "test")
defer release2()
require.NoError(t, err)
@ -163,7 +138,7 @@ func testLocker(t *testing.T, locker Locker) {
// and it shouldn't affect the other lock
release1()
ok, _, release3, err := locker.TryLock(ctx, "test")
ok, release3, err := locker.TryLock(ctx, "test")
defer release3()
require.NoError(t, err)
// It should be able to acquire the lock;
@ -184,28 +159,23 @@ func testRedisLocker(t *testing.T, locker *redisLocker) {
// Otherwise, it will affect other tests.
t.Run("close", func(t *testing.T) {
assert.NoError(t, locker.Close())
_, _, err := locker.Lock(context.Background(), "test")
_, err := locker.Lock(context.Background(), "test")
assert.Error(t, err)
})
}()
t.Run("failed extend", func(t *testing.T) {
ctx, release, err := locker.Lock(context.Background(), "test")
release, err := locker.Lock(context.Background(), "test")
defer release()
require.NoError(t, err)
// It simulates that there are some problems with extending like network issues or redis server down.
v, ok := locker.mutexM.Load("test")
require.True(t, ok)
m := v.(*redisMutex)
_, _ = m.mutex.Unlock() // release it to make it impossible to extend
m := v.(*redsync.Mutex)
_, _ = m.Unlock() // release it to make it impossible to extend
select {
case <-time.After(redisLockExpiry + time.Second):
t.Errorf("lock should be expired")
case <-ctx.Done():
var errTaken *redsync.ErrTaken
assert.ErrorAs(t, context.Cause(ctx), &errTaken)
}
// In current design, callers can't know the lock can't be extended.
// Just keep this case to improve the test coverage.
})
}

View File

@ -19,18 +19,13 @@ func NewMemoryLocker() Locker {
return &memoryLocker{}
}
func (l *memoryLocker) Lock(ctx context.Context, key string) (context.Context, ReleaseFunc, error) {
originalCtx := ctx
func (l *memoryLocker) Lock(ctx context.Context, key string) (ReleaseFunc, error) {
if l.tryLock(key) {
ctx, cancel := context.WithCancelCause(ctx)
releaseOnce := sync.Once{}
return ctx, func() context.Context {
return func() {
releaseOnce.Do(func() {
l.locks.Delete(key)
cancel(ErrLockReleased)
})
return originalCtx
}, nil
}
@ -39,39 +34,31 @@ func (l *memoryLocker) Lock(ctx context.Context, key string) (context.Context, R
for {
select {
case <-ctx.Done():
return ctx, func() context.Context { return originalCtx }, ctx.Err()
return func() {}, ctx.Err()
case <-ticker.C:
if l.tryLock(key) {
ctx, cancel := context.WithCancelCause(ctx)
releaseOnce := sync.Once{}
return ctx, func() context.Context {
return func() {
releaseOnce.Do(func() {
l.locks.Delete(key)
cancel(ErrLockReleased)
})
return originalCtx
}, nil
}
}
}
}
func (l *memoryLocker) TryLock(ctx context.Context, key string) (bool, context.Context, ReleaseFunc, error) {
originalCtx := ctx
func (l *memoryLocker) TryLock(_ context.Context, key string) (bool, ReleaseFunc, error) {
if l.tryLock(key) {
ctx, cancel := context.WithCancelCause(ctx)
releaseOnce := sync.Once{}
return true, ctx, func() context.Context {
return true, func() {
releaseOnce.Do(func() {
cancel(ErrLockReleased)
l.locks.Delete(key)
})
return originalCtx
}, nil
}
return false, ctx, func() context.Context { return originalCtx }, nil
return false, func() {}, nil
}
func (l *memoryLocker) tryLock(key string) bool {

View File

@ -48,21 +48,21 @@ func NewRedisLocker(connection string) Locker {
return l
}
func (l *redisLocker) Lock(ctx context.Context, key string) (context.Context, ReleaseFunc, error) {
func (l *redisLocker) Lock(ctx context.Context, key string) (ReleaseFunc, error) {
return l.lock(ctx, key, 0)
}
func (l *redisLocker) TryLock(ctx context.Context, key string) (bool, context.Context, ReleaseFunc, error) {
ctx, f, err := l.lock(ctx, key, 1)
func (l *redisLocker) TryLock(ctx context.Context, key string) (bool, ReleaseFunc, error) {
f, err := l.lock(ctx, key, 1)
var (
errTaken *redsync.ErrTaken
errNodeTaken *redsync.ErrNodeTaken
)
if errors.As(err, &errTaken) || errors.As(err, &errNodeTaken) {
return false, ctx, f, nil
return false, f, nil
}
return err == nil, ctx, f, err
return err == nil, f, err
}
// Close closes the locker.
@ -76,18 +76,11 @@ func (l *redisLocker) Close() error {
return nil
}
type redisMutex struct {
mutex *redsync.Mutex
cancel context.CancelCauseFunc
}
func (l *redisLocker) lock(ctx context.Context, key string, tries int) (context.Context, ReleaseFunc, error) {
func (l *redisLocker) lock(ctx context.Context, key string, tries int) (ReleaseFunc, error) {
if l.closed.Load() {
return ctx, func() context.Context { return ctx }, fmt.Errorf("locker is closed")
return func() {}, fmt.Errorf("locker is closed")
}
originalCtx := ctx
options := []redsync.Option{
redsync.WithExpiry(redisLockExpiry),
}
@ -96,18 +89,13 @@ func (l *redisLocker) lock(ctx context.Context, key string, tries int) (context.
}
mutex := l.rs.NewMutex(redisLockKeyPrefix+key, options...)
if err := mutex.LockContext(ctx); err != nil {
return ctx, func() context.Context { return originalCtx }, err
return func() {}, err
}
ctx, cancel := context.WithCancelCause(ctx)
l.mutexM.Store(key, &redisMutex{
mutex: mutex,
cancel: cancel,
})
l.mutexM.Store(key, mutex)
releaseOnce := sync.Once{}
return ctx, func() context.Context {
return func() {
releaseOnce.Do(func() {
l.mutexM.Delete(key)
@ -115,10 +103,7 @@ func (l *redisLocker) lock(ctx context.Context, key string, tries int) (context.
// if it failed to unlock, it will be released automatically after the lock expires.
// Do not call mutex.UnlockContext(ctx) here, or it will fail to release when ctx has timed out.
_, _ = mutex.Unlock()
cancel(ErrLockReleased)
})
return originalCtx
}, nil
}
@ -128,16 +113,15 @@ func (l *redisLocker) startExtend() {
return
}
toExtend := make([]*redisMutex, 0)
toExtend := make([]*redsync.Mutex, 0)
l.mutexM.Range(func(_, value any) bool {
m := value.(*redisMutex)
m := value.(*redsync.Mutex)
// Extend the lock if it is not expired.
// Although the mutex will be removed from the map before it is released,
// it still can be expired because of a failed extension.
// If it happens, the cancel function should have been called,
// so it does not need to be extended anymore.
if time.Now().After(m.mutex.Until()) {
// If it happens, it does not need to be extended anymore.
if time.Now().After(m.Until()) {
return true
}
@ -145,9 +129,8 @@ func (l *redisLocker) startExtend() {
return true
})
for _, v := range toExtend {
if ok, err := v.mutex.Extend(); !ok {
v.cancel(err)
}
// If it failed to extend, it will be released automatically after the lock expires.
_, _ = v.Extend()
}
time.AfterFunc(redisLockExpiry/2, l.startExtend)

View File

@ -628,6 +628,7 @@ org_still_own_repo=組織はまだ1つ以上のリポジトリを所有してい
org_still_own_packages=組織はまだ1つ以上のパッケージを所有しています。 先にそれらを削除してください。
target_branch_not_exist=ターゲットのブランチが存在していません。
target_ref_not_exist=ターゲットの ref が存在しません %s
admin_cannot_delete_self=あなたが管理者である場合、自分自身を削除することはできません。最初に管理者権限を削除してください。
@ -1273,6 +1274,7 @@ commit_graph.color=カラー
commit.contained_in=このコミットが含まれているのは:
commit.contained_in_default_branch=このコミットはデフォルトブランチに含まれています
commit.load_referencing_branches_and_tags=このコミットを参照しているブランチやタグを取得
commit.load_tags_failed=内部エラーによりタグの読み込みに失敗しました
blame=Blame
download_file=ファイルをダウンロード
normal_view=通常表示
@ -2185,6 +2187,7 @@ settings.transfer_in_progress=現在進行中の移転があります。この
settings.transfer_notices_1=- 個人ユーザーに移転すると、あなたはリポジトリへのアクセス権を失います。
settings.transfer_notices_2=- あなたが所有(または共同で所有)している組織に移転すると、リポジトリへのアクセス権は維持されます。
settings.transfer_notices_3=- プライベートリポジトリを個人ユーザーに移転した場合は、最低限そのユーザーが読み取り権限を持つよう設定されます (必要に応じて権限が変更されます)。
settings.transfer_notices_4=- リポジトリが組織に属しており、それを他の組織や個人に移転した場合、そのリポジトリのイシューと組織のプロジェクトボードとのリンクが失われます。
settings.transfer_owner=新しいオーナー
settings.transfer_perform=転送を実行
settings.transfer_started=このリポジトリは転送のためにマークされており、「%s」からの確認を待っています
@ -3699,6 +3702,11 @@ workflow.disable_success=ワークフロー '%s' が無効になりました。
workflow.enable=ワークフローを有効にする
workflow.enable_success=ワークフロー '%s' が有効になりました。
workflow.disabled=ワークフローは無効です。
workflow.run=ワークフローを実行
workflow.not_found=ワークフロー '%s' が見つかりません。
workflow.run_success=ワークフロー '%s' は正常に実行されました。
workflow.from_ref=使用するワークフローの取得元
workflow.has_workflow_dispatch=このワークフローには workflow_dispatch イベントトリガーがあります。
need_approval_desc=フォークプルリクエストのワークフローを実行するには承認が必要です。

4130
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -17,12 +17,12 @@
"add-asset-webpack-plugin": "3.0.0",
"ansi_up": "6.0.2",
"asciinema-player": "3.8.0",
"chart.js": "4.4.3",
"chart.js": "4.4.4",
"chartjs-adapter-dayjs-4": "1.0.4",
"chartjs-plugin-zoom": "2.0.1",
"clippie": "4.1.3",
"css-loader": "7.1.2",
"dayjs": "1.11.12",
"dayjs": "1.11.13",
"dropzone": "6.0.0-beta.2",
"easymde": "2.18.0",
"esbuild-loader": "4.2.2",
@ -34,17 +34,17 @@
"katex": "0.16.11",
"license-checker-webpack-plugin": "0.2.1",
"mermaid": "11.0.2",
"mini-css-extract-plugin": "2.9.0",
"mini-css-extract-plugin": "2.9.1",
"minimatch": "10.0.1",
"monaco-editor": "0.50.0",
"monaco-editor": "0.51.0",
"monaco-editor-webpack-plugin": "7.1.0",
"pdfobject": "2.3.0",
"postcss": "8.4.40",
"postcss": "8.4.41",
"postcss-loader": "8.1.1",
"postcss-nesting": "12.1.5",
"postcss-nesting": "13.0.0",
"sortablejs": "1.15.2",
"swagger-ui-dist": "5.17.14",
"tailwindcss": "3.4.7",
"tailwindcss": "3.4.10",
"temporal-polyfill": "0.2.5",
"throttle-debounce": "5.0.2",
"tinycolor2": "1.6.0",
@ -54,20 +54,20 @@
"typescript": "5.5.4",
"uint8-to-base64": "0.2.0",
"vanilla-colorful": "0.7.2",
"vue": "3.4.35",
"vue": "3.4.38",
"vue-bar-graph": "2.1.0",
"vue-chartjs": "5.3.1",
"vue-loader": "17.4.2",
"webpack": "5.93.0",
"webpack": "5.94.0",
"webpack-cli": "5.1.4",
"wrap-ansi": "9.0.0"
},
"devDependencies": {
"@eslint-community/eslint-plugin-eslint-comments": "4.3.0",
"@playwright/test": "1.45.3",
"@eslint-community/eslint-plugin-eslint-comments": "4.4.0",
"@playwright/test": "1.46.1",
"@stoplight/spectral-cli": "6.11.1",
"@stylistic/eslint-plugin-js": "2.6.1",
"@stylistic/stylelint-plugin": "3.0.0",
"@stylistic/eslint-plugin-js": "2.6.5",
"@stylistic/stylelint-plugin": "3.0.1",
"@types/dropzone": "5.7.8",
"@types/jquery": "3.5.30",
"@types/katex": "0.16.7",
@ -78,11 +78,11 @@
"@types/throttle-debounce": "5.0.2",
"@types/tinycolor2": "1.4.6",
"@types/toastify-js": "1.12.3",
"@typescript-eslint/eslint-plugin": "8.0.0",
"@typescript-eslint/parser": "8.0.0",
"@typescript-eslint/eslint-plugin": "8.3.0",
"@typescript-eslint/parser": "8.3.0",
"@vitejs/plugin-vue": "5.1.2",
"eslint": "8.57.0",
"eslint-import-resolver-typescript": "3.6.1",
"eslint-import-resolver-typescript": "3.6.3",
"eslint-plugin-array-func": "4.0.0",
"eslint-plugin-deprecation": "3.0.0",
"eslint-plugin-github": "5.0.1",
@ -91,14 +91,14 @@
"eslint-plugin-no-use-extend-native": "0.5.0",
"eslint-plugin-playwright": "1.6.2",
"eslint-plugin-regexp": "2.6.0",
"eslint-plugin-sonarjs": "1.0.4",
"eslint-plugin-sonarjs": "2.0.1",
"eslint-plugin-unicorn": "55.0.0",
"eslint-plugin-vitest": "0.4.1",
"eslint-plugin-vitest-globals": "1.5.0",
"eslint-plugin-vue": "9.27.0",
"eslint-plugin-vue-scoped-css": "2.8.1",
"eslint-plugin-wc": "2.1.0",
"happy-dom": "14.12.3",
"eslint-plugin-wc": "2.1.1",
"happy-dom": "15.3.1",
"markdownlint-cli": "0.41.0",
"nolyfill": "1.0.39",
"postcss-html": "1.7.0",
@ -107,8 +107,8 @@
"stylelint-declaration-strict-value": "1.10.6",
"stylelint-value-no-unknown-custom-properties": "6.0.1",
"svgo": "3.3.2",
"type-fest": "4.23.0",
"updates": "16.3.7",
"type-fest": "4.26.0",
"updates": "16.4.0",
"vite-string-plugin": "1.3.4",
"vitest": "2.0.5"
},
@ -131,6 +131,10 @@
"object.values": "npm:@nolyfill/object.values@^1",
"safe-regex-test": "npm:@nolyfill/safe-regex-test@^1",
"string.prototype.includes": "npm:@nolyfill/string.prototype.includes@^1",
"is-core-module": "npm:@nolyfill/is-core-module@^1"
"is-core-module": "npm:@nolyfill/is-core-module@^1",
"array.prototype.findlast": "npm:@nolyfill/array.prototype.findlast@^1",
"array.prototype.tosorted": "npm:@nolyfill/array.prototype.tosorted@^1",
"string.prototype.matchall": "npm:@nolyfill/string.prototype.matchall@^1",
"string.prototype.repeat": "npm:@nolyfill/string.prototype.repeat@^1"
}
}

10
poetry.lock generated
View File

@ -42,13 +42,13 @@ six = ">=1.13.0"
[[package]]
name = "djlint"
version = "1.34.1"
version = "1.34.2"
description = "HTML Template Linter and Formatter"
optional = false
python-versions = ">=3.8.0,<4.0.0"
python-versions = "<4.0.0,>=3.8.0"
files = [
{file = "djlint-1.34.1-py3-none-any.whl", hash = "sha256:96ff1c464fb6f061130ebc88663a2ea524d7ec51f4b56221a2b3f0320a3cfce8"},
{file = "djlint-1.34.1.tar.gz", hash = "sha256:db93fa008d19eaadb0454edf1704931d14469d48508daba2df9941111f408346"},
{file = "djlint-1.34.2-py3-none-any.whl", hash = "sha256:4825389e395eb77371857c77f547fa5ebd1a644b1bc4fe9fed19d49a2786b9e5"},
{file = "djlint-1.34.2.tar.gz", hash = "sha256:db9b2e59203a452b83532499bc243c749279090b905cc1f657973f78e7a31ddd"},
]
[package.dependencies]
@ -357,4 +357,4 @@ dev = ["doc8", "flake8", "flake8-import-order", "rstcheck[sphinx]", "sphinx"]
[metadata]
lock-version = "2.0"
python-versions = "^3.10"
content-hash = "cd2ff218e9f27a464dfbc8ec2387824a90f4360e04c3f2e58cc375796b7df33a"
content-hash = "af89bce0c442463621b6e536f9b94c31e188e1662c2caa84372c0858a2ee7d5c"

View File

@ -5,7 +5,7 @@ package-mode = false
python = "^3.10"
[tool.poetry.group.dev.dependencies]
djlint = "1.34.1"
djlint = "1.34.2"
yamllint = "1.35.1"
[tool.djlint]

View File

@ -883,10 +883,15 @@ func registerRoutes(m *web.Router) {
m.Post("/teams/{team}/action/repo/{action}", org.TeamsRepoAction)
}, context.OrgAssignment(true, false, true))
// require admin permission
m.Group("/{org}", func() {
m.Get("/teams/-/search", org.SearchTeam)
}, context.OrgAssignment(true, false, false, true))
// require owner permission
m.Group("/{org}", func() {
m.Get("/teams/new", org.NewTeam)
m.Post("/teams/new", web.Bind(forms.CreateTeamForm{}), org.NewTeamPost)
m.Get("/teams/-/search", org.SearchTeam)
m.Get("/teams/{team}/edit", org.EditTeam)
m.Post("/teams/{team}/edit", web.Bind(forms.CreateTeamForm{}), org.EditTeamPost)
m.Post("/teams/{team}/delete", org.DeleteTeam)

View File

@ -29,7 +29,7 @@
"sourceMap": true,
"types": [
"vitest/globals",
"./types.d.ts",
"./web_src/js/globals.d.ts",
],
}
}

31
types.d.ts vendored
View File

@ -1,31 +0,0 @@
declare module '*.svg' {
const value: string;
export default value;
}
declare module '*.css' {
const value: string;
export default value;
}
declare let __webpack_public_path__: string;
interface Window {
config: import('./web_src/js/types.ts').Config;
$: typeof import('@types/jquery'),
jQuery: typeof import('@types/jquery'),
htmx: typeof import('htmx.org'),
_globalHandlerErrors: Array<ErrorEvent & PromiseRejectionEvent> & {
_inited: boolean,
push: (e: ErrorEvent & PromiseRejectionEvent) => void | number,
},
}
declare module 'htmx.org/dist/htmx.esm.js' {
const value = await import('htmx.org');
export default value;
}
interface Element {
_tippy: import('tippy.js').Instance;
}

View File

@ -252,6 +252,12 @@ const sfc = {
// initial states for job steps
this.currentJobStepsStates[i] = {cursor: null, expanded: false};
}
// expands the currently running job step if its state wasn't 'running' before
if (this.currentJob.steps[i].status === 'running' && this.currentJobStepsStates[i].cursor === null) {
this.currentJobStepsStates[i].cursor = 0;
this.currentJobStepsStates[i].expanded = true;
}
}
// append logs to the UI
for (const logs of job.logs.stepsLog) {

61
web_src/js/globals.d.ts vendored Normal file
View File

@ -0,0 +1,61 @@
declare module '*.svg' {
const value: string;
export default value;
}
declare module '*.css' {
const value: string;
export default value;
}
declare let __webpack_public_path__: string;
declare module 'htmx.org/dist/htmx.esm.js' {
const value = await import('htmx.org');
export default value;
}
declare module 'uint8-to-base64' {
export function encode(arrayBuffer: ArrayBuffer): string;
export function decode(base64str: string): ArrayBuffer;
}
declare module 'swagger-ui-dist/swagger-ui-es-bundle.js' {
const value = await import('swagger-ui-dist');
export default value.SwaggerUIBundle;
}
interface JQuery {
api: any, // fomantic
areYouSure: any, // jquery.are-you-sure
dimmer: any, // fomantic
dropdown: any; // fomantic
modal: any; // fomantic
tab: any; // fomantic
transition: any, // fomantic
}
interface JQueryStatic {
api: any, // fomantic
}
interface Element {
_tippy: import('tippy.js').Instance;
}
type Writable<T> = { -readonly [K in keyof T]: T[K] };
interface Window {
config: import('./web_src/js/types.ts').Config;
$: typeof import('@types/jquery'),
jQuery: typeof import('@types/jquery'),
htmx: Omit<typeof import('htmx.org/dist/htmx.esm.js').default, 'config'> & {
config?: Writable<typeof import('htmx.org').default.config>,
},
ui?: any,
_globalHandlerErrors: Array<ErrorEvent & PromiseRejectionEvent> & {
_inited: boolean,
push: (e: ErrorEvent & PromiseRejectionEvent) => void | number,
},
__webpack_public_path__: string;
}

View File

@ -1,20 +1,21 @@
import {showErrorToast} from './modules/toast.ts';
import 'idiomorph/dist/idiomorph-ext.js'; // https://github.com/bigskysoftware/idiomorph#htmx
import type {HtmxResponseInfo} from 'htmx.org';
// https://github.com/bigskysoftware/idiomorph#htmx
import 'idiomorph/dist/idiomorph-ext.js';
type HtmxEvent = Event & {detail: HtmxResponseInfo};
// https://htmx.org/reference/#config
window.htmx.config.requestClass = 'is-loading';
window.htmx.config.scrollIntoViewOnBoost = false;
// https://htmx.org/events/#htmx:sendError
document.body.addEventListener('htmx:sendError', (event) => {
document.body.addEventListener('htmx:sendError', (event: HtmxEvent) => {
// TODO: add translations
showErrorToast(`Network error when calling ${event.detail.requestConfig.path}`);
});
// https://htmx.org/events/#htmx:responseError
document.body.addEventListener('htmx:responseError', (event) => {
document.body.addEventListener('htmx:responseError', (event: HtmxEvent) => {
// TODO: add translations
showErrorToast(`Error ${event.detail.xhr.status} when calling ${event.detail.requestConfig.path}`);
});

View File

@ -98,12 +98,12 @@ initGiteaFomantic();
initDirAuto();
initSubmitEventPolyfill();
function callInitFunctions(functions) {
function callInitFunctions(functions: (() => any)[]) {
// Start performance trace by accessing a URL by "https://localhost/?_ui_performance_trace=1" or "https://localhost/?key=value&_ui_performance_trace=1"
// It is a quick check, no side effect so no need to do slow URL parsing.
const initStart = performance.now();
if (window.location.search.includes('_ui_performance_trace=1')) {
let results = [];
let results: {name: string, dur: number}[] = [];
for (const func of functions) {
const start = performance.now();
func();

View File

@ -1,12 +1,12 @@
import {AnsiUp} from 'ansi_up';
const replacements = [
const replacements: Array<[RegExp, string]> = [
[/\x1b\[\d+[A-H]/g, ''], // Move cursor, treat them as no-op
[/\x1b\[\d?[JK]/g, '\r'], // Erase display/line, treat them as a Carriage Return
];
// render ANSI to HTML
export function renderAnsi(line) {
export function renderAnsi(line: string): string {
// create a fresh ansi_up instance because otherwise previous renders can influence
// the output of future renders, because ansi_up is stateful and remembers things like
// unclosed opening tags for colors.

View File

@ -8,7 +8,7 @@ window.addEventListener('load', async () => {
// Make the page's protocol be at the top of the schemes list
const proto = window.location.protocol.slice(0, -1);
spec.schemes.sort((a, b) => {
spec.schemes.sort((a: string, b: string) => {
if (a === proto) return -1;
if (b === proto) return 1;
return 0;

View File

@ -17,7 +17,7 @@ test('svgParseOuterInner', () => {
test('SvgIcon', () => {
const root = document.createElement('div');
createApp({render: () => h(SvgIcon, {name: 'octicon-link', size: 24, class: 'base', className: 'extra'})}).mount(root);
const node = root.firstChild;
const node = root.firstChild as Element;
expect(node.nodeName).toEqual('svg');
expect(node.getAttribute('width')).toEqual('24');
expect(node.getAttribute('height')).toEqual('24');

View File

@ -29,3 +29,10 @@ export type RequestData = string | FormData | URLSearchParams;
export type RequestOpts = {
data?: RequestData,
} & RequestInit;
export type IssueData = {
owner: string,
repo: string,
type: string,
index: string,
}

View File

@ -95,23 +95,20 @@ test('toAbsoluteUrl', () => {
});
test('encodeURLEncodedBase64, decodeURLEncodedBase64', () => {
// TextEncoder is Node.js API while Uint8Array is jsdom API and their outputs are not
// structurally comparable, so we convert to array to compare. The conversion can be
// removed once https://github.com/jsdom/jsdom/issues/2524 is resolved.
const encoder = new TextEncoder();
const uint8array = encoder.encode.bind(encoder);
expect(encodeURLEncodedBase64(uint8array('AA?'))).toEqual('QUE_'); // standard base64: "QUE/"
expect(encodeURLEncodedBase64(uint8array('AA~'))).toEqual('QUF-'); // standard base64: "QUF+"
expect(Array.from(decodeURLEncodedBase64('QUE/'))).toEqual(Array.from(uint8array('AA?')));
expect(Array.from(decodeURLEncodedBase64('QUF+'))).toEqual(Array.from(uint8array('AA~')));
expect(Array.from(decodeURLEncodedBase64('QUE_'))).toEqual(Array.from(uint8array('AA?')));
expect(Array.from(decodeURLEncodedBase64('QUF-'))).toEqual(Array.from(uint8array('AA~')));
expect(new Uint8Array(decodeURLEncodedBase64('QUE/'))).toEqual(uint8array('AA?'));
expect(new Uint8Array(decodeURLEncodedBase64('QUF+'))).toEqual(uint8array('AA~'));
expect(new Uint8Array(decodeURLEncodedBase64('QUE_'))).toEqual(uint8array('AA?'));
expect(new Uint8Array(decodeURLEncodedBase64('QUF-'))).toEqual(uint8array('AA~'));
expect(encodeURLEncodedBase64(uint8array('a'))).toEqual('YQ'); // standard base64: "YQ=="
expect(Array.from(decodeURLEncodedBase64('YQ'))).toEqual(Array.from(uint8array('a')));
expect(Array.from(decodeURLEncodedBase64('YQ=='))).toEqual(Array.from(uint8array('a')));
expect(new Uint8Array(decodeURLEncodedBase64('YQ'))).toEqual(uint8array('a'));
expect(new Uint8Array(decodeURLEncodedBase64('YQ=='))).toEqual(uint8array('a'));
});
test('file detection', () => {

View File

@ -1,13 +1,14 @@
import {encode, decode} from 'uint8-to-base64';
import type {IssueData} from './types.ts';
// transform /path/to/file.ext to file.ext
export function basename(path) {
export function basename(path: string): string {
const lastSlashIndex = path.lastIndexOf('/');
return lastSlashIndex < 0 ? path : path.substring(lastSlashIndex + 1);
}
// transform /path/to/file.ext to .ext
export function extname(path) {
export function extname(path: string): string {
const lastSlashIndex = path.lastIndexOf('/');
const lastPointIndex = path.lastIndexOf('.');
if (lastSlashIndex > lastPointIndex) return '';
@ -15,54 +16,54 @@ export function extname(path) {
}
// test whether a variable is an object
export function isObject(obj) {
export function isObject(obj: any): boolean {
return Object.prototype.toString.call(obj) === '[object Object]';
}
// returns whether a dark theme is enabled
export function isDarkTheme() {
export function isDarkTheme(): boolean {
const style = window.getComputedStyle(document.documentElement);
return style.getPropertyValue('--is-dark-theme').trim().toLowerCase() === 'true';
}
// strip <tags> from a string
export function stripTags(text) {
export function stripTags(text: string): string {
return text.replace(/<[^>]*>?/g, '');
}
export function parseIssueHref(href) {
export function parseIssueHref(href: string): IssueData {
const path = (href || '').replace(/[#?].*$/, '');
const [_, owner, repo, type, index] = /([^/]+)\/([^/]+)\/(issues|pulls)\/([0-9]+)/.exec(path) || [];
return {owner, repo, type, index};
}
// parse a URL, either relative '/path' or absolute 'https://localhost/path'
export function parseUrl(str) {
export function parseUrl(str: string): URL {
return new URL(str, str.startsWith('http') ? undefined : window.location.origin);
}
// return current locale chosen by user
export function getCurrentLocale() {
export function getCurrentLocale(): string {
return document.documentElement.lang;
}
// given a month (0-11), returns it in the documents language
export function translateMonth(month) {
export function translateMonth(month: number) {
return new Date(Date.UTC(2022, month, 12)).toLocaleString(getCurrentLocale(), {month: 'short', timeZone: 'UTC'});
}
// given a weekday (0-6, Sunday to Saturday), returns it in the documents language
export function translateDay(day) {
export function translateDay(day: number) {
return new Date(Date.UTC(2022, 7, day)).toLocaleString(getCurrentLocale(), {weekday: 'short', timeZone: 'UTC'});
}
// convert a Blob to a DataURI
export function blobToDataURI(blob) {
export function blobToDataURI(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
try {
const reader = new FileReader();
reader.addEventListener('load', (e) => {
resolve(e.target.result);
resolve(e.target.result as string);
});
reader.addEventListener('error', () => {
reject(new Error('FileReader failed'));
@ -75,7 +76,7 @@ export function blobToDataURI(blob) {
}
// convert image Blob to another mime-type format.
export function convertImage(blob, mime) {
export function convertImage(blob: Blob, mime: string): Promise<Blob> {
return new Promise(async (resolve, reject) => {
try {
const img = new Image();
@ -104,7 +105,7 @@ export function convertImage(blob, mime) {
});
}
export function toAbsoluteUrl(url) {
export function toAbsoluteUrl(url: string): string {
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
@ -118,15 +119,15 @@ export function toAbsoluteUrl(url) {
}
// Encode an ArrayBuffer into a URLEncoded base64 string.
export function encodeURLEncodedBase64(arrayBuffer) {
export function encodeURLEncodedBase64(arrayBuffer: ArrayBuffer): string {
return encode(arrayBuffer)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
// Decode a URLEncoded base64 to an ArrayBuffer string.
export function decodeURLEncodedBase64(base64url) {
// Decode a URLEncoded base64 to an ArrayBuffer.
export function decodeURLEncodedBase64(base64url: string): ArrayBuffer {
return decode(base64url
.replace(/_/g, '/')
.replace(/-/g, '+'));
@ -135,20 +136,22 @@ export function decodeURLEncodedBase64(base64url) {
const domParser = new DOMParser();
const xmlSerializer = new XMLSerializer();
export function parseDom(text, contentType) {
export function parseDom(text: string, contentType: DOMParserSupportedType): Document {
return domParser.parseFromString(text, contentType);
}
export function serializeXml(node) {
export function serializeXml(node: Element | Node): string {
return xmlSerializer.serializeToString(node);
}
export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function isImageFile({name, type}) {
export function isImageFile({name, type}: {name: string, type?: string}): boolean {
return /\.(jpe?g|png|gif|webp|svg|heic)$/i.test(name || '') || type?.startsWith('image/');
}
export function isVideoFile({name, type}) {
export function isVideoFile({name, type}: {name: string, type?: string}): boolean {
return /\.(mpe?g|mp4|mkv|webm)$/i.test(name || '') || type?.startsWith('video/');
}

View File

@ -3,23 +3,23 @@ import type {ColorInput} from 'tinycolor2';
// Returns relative luminance for a SRGB color - https://en.wikipedia.org/wiki/Relative_luminance
// Keep this in sync with modules/util/color.go
function getRelativeLuminance(color: ColorInput) {
function getRelativeLuminance(color: ColorInput): number {
const {r, g, b} = tinycolor(color).toRgb();
return (0.2126729 * r + 0.7151522 * g + 0.072175 * b) / 255;
}
function useLightText(backgroundColor: ColorInput) {
function useLightText(backgroundColor: ColorInput): boolean {
return getRelativeLuminance(backgroundColor) < 0.453;
}
// Given a background color, returns a black or white foreground color that the highest
// contrast ratio. In the future, the APCA contrast function, or CSS `contrast-color` will be better.
// https://github.com/color-js/color.js/blob/eb7b53f7a13bb716ec8b28c7a56f052cd599acd9/src/contrast/APCA.js#L42
export function contrastColor(backgroundColor: ColorInput) {
export function contrastColor(backgroundColor: ColorInput): string {
return useLightText(backgroundColor) ? '#fff' : '#000';
}
function resolveColors(obj: Record<string, string>) {
function resolveColors(obj: Record<string, string>): Record<string, string> {
const styles = window.getComputedStyle(document.documentElement);
const getColor = (name: string) => styles.getPropertyValue(name).trim();
return Object.fromEntries(Object.entries(obj).map(([key, value]) => [key, getColor(value)]));

View File

@ -266,10 +266,8 @@ export function initSubmitEventPolyfill() {
/**
* Check if an element is visible, equivalent to jQuery's `:visible` pseudo.
* Note: This function doesn't account for all possible visibility scenarios.
* @param {HTMLElement} element The element to check.
* @returns {boolean} True if the element is visible.
*/
export function isElemVisible(element: HTMLElement) {
export function isElemVisible(element: HTMLElement): boolean {
if (!element) return false;
return Boolean(element.offsetWidth || element.offsetHeight || element.getClientRects().length);

View File

@ -1,6 +1,11 @@
export async function pngChunks(blob) {
type PngChunk = {
name: string,
data: Uint8Array,
}
export async function pngChunks(blob: Blob): Promise<PngChunk[]> {
const uint8arr = new Uint8Array(await blob.arrayBuffer());
const chunks = [];
const chunks: PngChunk[] = [];
if (uint8arr.length < 12) return chunks;
const view = new DataView(uint8arr.buffer);
if (view.getBigUint64(0) !== 9894494448401390090n) return chunks;
@ -19,9 +24,14 @@ export async function pngChunks(blob) {
return chunks;
}
type ImageInfo = {
width?: number,
dppx?: number,
}
// decode a image and try to obtain width and dppx. It will never throw but instead
// return default values.
export async function imageInfo(blob) {
export async function imageInfo(blob: Blob): Promise<ImageInfo> {
let width = 0, dppx = 1; // dppx: 1 dot per pixel for non-HiDPI screens
if (blob.type === 'image/png') { // only png is supported currently

View File

@ -2,17 +2,17 @@ import emojis from '../../../assets/emoji.json';
const maxMatches = 6;
function sortAndReduce(map) {
function sortAndReduce(map: Map<string, number>) {
const sortedMap = new Map(Array.from(map.entries()).sort((a, b) => a[1] - b[1]));
return Array.from(sortedMap.keys()).slice(0, maxMatches);
}
export function matchEmoji(queryText) {
export function matchEmoji(queryText: string): string[] {
const query = queryText.toLowerCase().replaceAll('_', ' ');
if (!query) return emojis.slice(0, maxMatches).map((e) => e.aliases[0]);
// results is a map of weights, lower is better
const results = new Map();
const results = new Map<string, number>();
for (const {aliases} of emojis) {
const mainAlias = aliases[0];
for (const [aliasIndex, alias] of aliases.entries()) {
@ -27,7 +27,7 @@ export function matchEmoji(queryText) {
return sortAndReduce(results);
}
export function matchMention(queryText) {
export function matchMention(queryText: string): string[] {
const query = queryText.toLowerCase();
// results is a map of weights, lower is better

View File

@ -1,16 +1,17 @@
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc.js';
import {getCurrentLocale} from '../utils.ts';
import type {ConfigType} from 'dayjs';
dayjs.extend(utc);
/**
* Returns an array of millisecond-timestamps of start-of-week days (Sundays)
*
* @param startConfig The start date. Can take any type that `Date` accepts.
* @param endConfig The end date. Can take any type that `Date` accepts.
* @param startDate The start date. Can take any type that dayjs accepts.
* @param endDate The end date. Can take any type that dayjs accepts.
*/
export function startDaysBetween(startDate, endDate) {
export function startDaysBetween(startDate: ConfigType, endDate: ConfigType): number[] {
const start = dayjs.utc(startDate);
const end = dayjs.utc(endDate);
@ -21,7 +22,7 @@ export function startDaysBetween(startDate, endDate) {
current = current.add(1, 'day');
}
const startDays = [];
const startDays: number[] = [];
while (current.isBefore(end)) {
startDays.push(current.valueOf());
current = current.add(1, 'week');
@ -30,7 +31,7 @@ export function startDaysBetween(startDate, endDate) {
return startDays;
}
export function firstStartDateAfterDate(inputDate) {
export function firstStartDateAfterDate(inputDate: Date): number {
if (!(inputDate instanceof Date)) {
throw new Error('Invalid date');
}
@ -41,7 +42,14 @@ export function firstStartDateAfterDate(inputDate) {
return resultDate.valueOf();
}
export function fillEmptyStartDaysWithZeroes(startDays, data) {
type DayData = {
week: number,
additions: number,
deletions: number,
commits: number,
}
export function fillEmptyStartDaysWithZeroes(startDays: number[], data: DayData): DayData[] {
const result = {};
for (const startDay of startDays) {
@ -51,11 +59,11 @@ export function fillEmptyStartDaysWithZeroes(startDays, data) {
return Object.values(result);
}
let dateFormat;
let dateFormat: Intl.DateTimeFormat;
// format a Date object to document's locale, but with 24h format from user's current locale because this
// option is a personal preference of the user, not something that the document's locale should dictate.
export function formatDatetime(date) {
export function formatDatetime(date: Date | number): string {
if (!dateFormat) {
// TODO: replace `hour12` with `Intl.Locale.prototype.getHourCycles` once there is broad browser support
dateFormat = new Intl.DateTimeFormat(getCurrentLocale(), {

View File

@ -1,12 +1,12 @@
export function pathEscapeSegments(s) {
export function pathEscapeSegments(s: string): string {
return s.split('/').map(encodeURIComponent).join('/');
}
function stripSlash(url) {
function stripSlash(url: string): string {
return url.endsWith('/') ? url.slice(0, -1) : url;
}
export function isUrl(url) {
export function isUrl(url: string): boolean {
try {
return stripSlash((new URL(url).href)).trim() === stripSlash(url).trim();
} catch {

View File

@ -1,10 +1,16 @@
window.__webpack_public_path__ = '';
window.config = {
appUrl: 'http://localhost:3000/',
appSubUrl: '',
assetVersionEncoded: '',
assetUrlPrefix: '',
runModeIsProd: true,
customEmojis: {},
csrfToken: 'test-csrf-token-123456',
pageData: {},
i18n: {},
appSubUrl: '',
notificationSettings: {},
enableTimeTracking: true,
mentionValues: [
{key: 'user1 User 1', value: 'user1', name: 'user1', fullname: 'User 1', avatar: 'https://avatar1.com'},
{key: 'user2 User 2', value: 'user2', name: 'user2', fullname: 'User 2', avatar: 'https://avatar2.com'},
@ -14,4 +20,6 @@ window.config = {
{key: 'org6 User 6', value: 'org6', name: 'org6', fullname: 'User 6', avatar: 'https://avatar6.com'},
{key: 'org7 User 7', value: 'org7', name: 'org7', fullname: 'User 7', avatar: 'https://avatar7.com'},
],
mermaidMaxSourceCharacters: 5000,
i18n: {},
};