mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-10-13 07:47:46 +08:00
feat(core): order projects by recent activity (#50790)
This commit is contained in:
@@ -93,7 +93,7 @@ describe("normalizeProviderList", () => {
|
||||
|
||||
describe("normalizeProjectInfo", () => {
|
||||
test("keeps the project VCS backend", () => {
|
||||
const project = { id: "prj", canonical: "/repo", time: { created: 1, updated: 1 }, sandboxes: [] }
|
||||
const project = { id: "prj", canonical: "/repo", time: { created: 1, updated: 1, active: 1 }, sandboxes: [] }
|
||||
expect(normalizeProjectInfo({ ...project, vcs: "git" } as Project).vcs).toBe("git")
|
||||
expect(normalizeProjectInfo({ ...project, vcs: "hg" } as Project).vcs).toBe("hg")
|
||||
expect(normalizeProjectInfo(project as Project).vcs).toBeUndefined()
|
||||
@@ -124,7 +124,7 @@ describe("updateProjectInfo", () => {
|
||||
canonical: "/repo",
|
||||
name: "Repo",
|
||||
icon: { color: "purple" },
|
||||
time: { created: 1, updated: 2 },
|
||||
time: { created: 1, updated: 2, active: 2 },
|
||||
sandboxes: ["/repo-sandbox"],
|
||||
} satisfies Project
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ test("settings project inventory reads metadata without acquiring directory stor
|
||||
name: `Project ${index}`,
|
||||
icon: { color: "orange" },
|
||||
commands: { start: "bun install" },
|
||||
time: { created: 1, updated: 1 },
|
||||
time: { created: 1, updated: 1, active: 1 },
|
||||
sandboxes: [],
|
||||
worktrees: [],
|
||||
}))
|
||||
|
||||
@@ -107,7 +107,7 @@ describe("withWorktreeInventory", () => {
|
||||
id: "project",
|
||||
canonical: "/repo",
|
||||
name: "Before",
|
||||
time: { created: 1, updated: 1 },
|
||||
time: { created: 1, updated: 1, active: 1 },
|
||||
sandboxes: [],
|
||||
}
|
||||
|
||||
|
||||
@@ -296,7 +296,7 @@ export type ProjectIcon = { url?: string; override?: string; color?: string }
|
||||
|
||||
export type ProjectCommands = { start?: string }
|
||||
|
||||
export type ProjectTime = { created: number; updated: number }
|
||||
export type ProjectTime = { created: number; updated: number; active: number }
|
||||
|
||||
export type PermissionSource = { type: "tool"; messageID: string; id: string }
|
||||
|
||||
|
||||
@@ -1788,7 +1788,7 @@ export function createData(config: CreateDataInput) {
|
||||
},
|
||||
project: {
|
||||
list() {
|
||||
return Object.values(store.project.info).toSorted((a, b) => b.time.updated - a.time.updated)
|
||||
return Object.values(store.project.info).toSorted((a, b) => b.time.active - a.time.active)
|
||||
},
|
||||
get(projectID: string) {
|
||||
return store.project.info[projectID]
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"id": "be60f352-8da1-40e1-8d70-dc41121cfbc5",
|
||||
"prevIds": ["3fb67508-0196-4bae-b2bd-c08ece7583fd"],
|
||||
"id": "e98619ef-c334-45e3-8c48-9932ed1ef290",
|
||||
"prevIds": ["be60f352-8da1-40e1-8d70-dc41121cfbc5"],
|
||||
"ddl": [
|
||||
{
|
||||
"name": "account_state",
|
||||
@@ -700,6 +700,16 @@
|
||||
"entityType": "columns",
|
||||
"table": "project"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "0",
|
||||
"generated": null,
|
||||
"name": "time_active",
|
||||
"entityType": "columns",
|
||||
"table": "project"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
|
||||
+2
@@ -46,6 +46,7 @@ import m43 from "./migration/20260812213948_worktree.js"
|
||||
import m44 from "./migration/20260819222447_session_viewed_state.js"
|
||||
import m45 from "./migration/20260823191254_nullable_workspace_binding.js"
|
||||
import m46 from "./migration/20260910120000_clear_v1_session_permission.js"
|
||||
import m47 from "./migration/20260923013825_project_time_active.js"
|
||||
|
||||
export const migrations = [
|
||||
m00,
|
||||
@@ -95,4 +96,5 @@ export const migrations = [
|
||||
m44,
|
||||
m45,
|
||||
m46,
|
||||
m47,
|
||||
] satisfies DatabaseMigration.Migration[]
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260923013825_project_time_active",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`ALTER TABLE \`project\` ADD \`time_active\` integer DEFAULT 0 NOT NULL;`)
|
||||
yield* tx.run(`UPDATE \`project\` SET \`time_active\` = \`time_updated\`;`)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export default migration
|
||||
@@ -110,6 +110,7 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
|
||||
\`time_created\` integer NOT NULL,
|
||||
\`time_updated\` integer NOT NULL,
|
||||
\`time_initialized\` integer,
|
||||
\`time_active\` integer DEFAULT 0 NOT NULL,
|
||||
\`sandboxes\` text NOT NULL,
|
||||
\`commands\` text
|
||||
);
|
||||
|
||||
@@ -523,8 +523,8 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
|
||||
if (!(yield* hasLegacySessions(db))) return { status: "completed" as const }
|
||||
const now = Date.now()
|
||||
yield* db.run(sql`
|
||||
INSERT OR IGNORE INTO project (id, worktree, time_created, time_updated, sandboxes)
|
||||
VALUES (${Project.ID.global}, ${path.parse(global.data).root}, ${now}, ${now}, '[]')
|
||||
INSERT OR IGNORE INTO project (id, worktree, time_created, time_updated, time_active, sandboxes)
|
||||
VALUES (${Project.ID.global}, ${path.parse(global.data).root}, ${now}, ${now}, ${now}, '[]')
|
||||
`)
|
||||
if (state === undefined)
|
||||
yield* db
|
||||
@@ -753,11 +753,11 @@ function importNextDatabase(
|
||||
yield* tx.run(sql`
|
||||
INSERT OR IGNORE INTO project (
|
||||
id, worktree, vcs, name, icon_url, icon_url_override, icon_color,
|
||||
time_created, time_updated, time_initialized, sandboxes, commands
|
||||
time_created, time_updated, time_initialized, time_active, sandboxes, commands
|
||||
) VALUES (
|
||||
${project.id}, ${project.worktree}, ${project.vcs}, ${project.name}, ${project.icon_url},
|
||||
${project.icon_url_override}, ${project.icon_color}, ${project.time_created}, ${project.time_updated},
|
||||
${project.time_initialized}, ${project.sandboxes}, ${project.commands}
|
||||
${project.time_initialized}, ${project.time_updated}, ${project.sandboxes}, ${project.commands}
|
||||
)
|
||||
`)
|
||||
const existing = yield* tx
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
export * as LocationLifecycle from "./location-lifecycle.js"
|
||||
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { makeLocationNode } from "@opencode/util/effect/app-node"
|
||||
import { LocationEvent } from "@opencode/schema/location-event"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Form } from "./form.js"
|
||||
import { Location } from "./location.js"
|
||||
import { LocationServiceMap } from "./location-service-map.js"
|
||||
import { Permission } from "./permission.js"
|
||||
import { Project } from "./project.js"
|
||||
import { Rpc } from "./rpc.js"
|
||||
import { SessionEvent } from "./session/event.js"
|
||||
|
||||
const isSessionEvent = Schema.is(SessionEvent.Durable)
|
||||
|
||||
export class Service extends Context.Service<
|
||||
Service,
|
||||
@@ -22,6 +27,17 @@ const layer = Layer.effect(
|
||||
const permission = yield* Permission.Service
|
||||
const forms = yield* Form.Service
|
||||
const rpc = yield* Rpc.Service
|
||||
const project = yield* Project.Service
|
||||
const scope = yield* Effect.scope
|
||||
yield* project.activate(location.project.id)
|
||||
const unsubscribe = yield* bus.listen((event) => {
|
||||
if (!isSessionEvent(event) || !event.location) return Effect.void
|
||||
const ref = LocationServiceMap.canonical(event.location)
|
||||
if (ref.directory !== location.directory || ref.workspaceID !== location.workspaceID) return Effect.void
|
||||
// Listeners run inside publish; keep the write and its Project event off that path.
|
||||
return project.activate(location.project.id).pipe(Effect.forkIn(scope), Effect.asVoid)
|
||||
})
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
let closed = false
|
||||
const shutdown = yield* Effect.cached(
|
||||
Effect.gen(function* () {
|
||||
@@ -48,5 +64,5 @@ const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Bus.node, Location.node, Permission.node, Form.node, Rpc.node],
|
||||
deps: [Bus.node, Location.node, Permission.node, Project.node, Form.node, Rpc.node],
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ export * as Project from "./project.js"
|
||||
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { and, asc, desc, eq, gte, isNull, lte } from "drizzle-orm"
|
||||
import { and, asc, desc, eq, gte, isNull, lte, sql } from "drizzle-orm"
|
||||
import path from "path"
|
||||
import { AbsolutePath } from "./schema.js"
|
||||
import { Bus } from "./bus.js"
|
||||
@@ -57,9 +57,13 @@ export const root = Effect.fn("Project.root")(function* (
|
||||
)
|
||||
})
|
||||
|
||||
const ACTIVATE_INTERVAL = 60_000
|
||||
|
||||
export interface Interface {
|
||||
readonly list: () => Effect.Effect<ReadonlyArray<Info>>
|
||||
readonly update: (input: UpdateInput) => Effect.Effect<Info, NotFoundError>
|
||||
/** Records Project activity for recency ordering, at most once per minute per Project. */
|
||||
readonly activate: (projectID: ID) => Effect.Effect<void>
|
||||
/** Resolves and persists the owning Project. */
|
||||
readonly resolve: (input: AbsolutePath, options?: { readonly discovery?: boolean }) => Effect.Effect<Resolved>
|
||||
}
|
||||
@@ -85,6 +89,7 @@ function fromRow(row: typeof ProjectTable.$inferSelect): Info {
|
||||
time: {
|
||||
created: row.time_created,
|
||||
updated: row.time_updated,
|
||||
active: row.time_active,
|
||||
},
|
||||
sandboxes: row.sandboxes,
|
||||
}
|
||||
@@ -201,7 +206,7 @@ const layer = Layer.effect(
|
||||
const rows = yield* db
|
||||
.select()
|
||||
.from(ProjectTable)
|
||||
.orderBy(desc(ProjectTable.time_updated), asc(ProjectTable.id))
|
||||
.orderBy(desc(ProjectTable.time_active), asc(ProjectTable.id))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
return rows.map(fromRow)
|
||||
@@ -233,6 +238,22 @@ const layer = Layer.effect(
|
||||
return project
|
||||
})
|
||||
|
||||
const activated = new Map<ID, number>()
|
||||
const activate = Effect.fn("Project.activate")(function* (projectID: ID) {
|
||||
const now = Date.now()
|
||||
if (now - (activated.get(projectID) ?? 0) < ACTIVATE_INTERVAL) return
|
||||
activated.set(projectID, now)
|
||||
const row = yield* db
|
||||
.update(ProjectTable)
|
||||
// Activity is not a metadata edit, so keep time_updated out of the implicit bump.
|
||||
.set({ time_active: now, time_updated: sql`${ProjectTable.time_updated}` })
|
||||
.where(eq(ProjectTable.id, projectID))
|
||||
.returning()
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (row) yield* bus.publish(ProjectSchema.Event.Updated, fromRow(row))
|
||||
})
|
||||
|
||||
const cached = Effect.fnUntraced(function* (dir: string) {
|
||||
return yield* fs.readFileString(path.join(dir, "opencode")).pipe(
|
||||
Effect.map((value) => value.trim()),
|
||||
@@ -361,7 +382,7 @@ const layer = Layer.effect(
|
||||
})
|
||||
})
|
||||
|
||||
return Service.of({ list, update, resolve })
|
||||
return Service.of({ list, update, activate, resolve })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -19,6 +19,10 @@ export const ProjectTable = sqliteTable("project", {
|
||||
icon_color: text(),
|
||||
...Timestamps,
|
||||
time_initialized: integer(),
|
||||
time_active: integer()
|
||||
.notNull()
|
||||
.default(0)
|
||||
.$defaultFn(() => Date.now()),
|
||||
sandboxes: absoluteArrayColumn().notNull(),
|
||||
commands: text({ mode: "json" }).$type<{ start?: string }>(),
|
||||
})
|
||||
|
||||
@@ -100,6 +100,7 @@ describe("node build", () => {
|
||||
return Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
update: () => Effect.die("not implemented"),
|
||||
activate: () => Effect.void,
|
||||
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -13,6 +13,7 @@ export const globalProjectNode = makeGlobalNode({
|
||||
return Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
update: () => Effect.die("not implemented"),
|
||||
activate: () => Effect.void,
|
||||
resolve: (directory) => {
|
||||
const project = { id: Project.ID.global, directory, canonical: directory }
|
||||
return upsertProject(database.db, project).pipe(Effect.orDie, Effect.as(project))
|
||||
|
||||
@@ -14,6 +14,7 @@ const projectLayer = Layer.succeed(
|
||||
Project.Service.of({
|
||||
list: () => Effect.succeed([]),
|
||||
update: () => Effect.die("not implemented"),
|
||||
activate: () => Effect.void,
|
||||
resolve: () =>
|
||||
Effect.succeed({
|
||||
id: Project.ID.make("project"),
|
||||
|
||||
@@ -18,7 +18,7 @@ import { testEffect } from "./lib/effect"
|
||||
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Project.node, Database.node, Bus.node])))
|
||||
|
||||
describe("Project.list", () => {
|
||||
it.effect("returns complete projects ordered by recent update", () =>
|
||||
it.effect("returns complete projects ordered by recent activity", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
const project = yield* Project.Service
|
||||
@@ -35,6 +35,7 @@ describe("Project.list", () => {
|
||||
sandboxes: [abs("/older/sandbox")],
|
||||
time_created: 1,
|
||||
time_updated: 1,
|
||||
time_active: 4,
|
||||
},
|
||||
{
|
||||
id: Project.ID.make("newer"),
|
||||
@@ -43,17 +44,12 @@ describe("Project.list", () => {
|
||||
time_created: 2,
|
||||
time_updated: 2,
|
||||
time_initialized: 3,
|
||||
time_active: 3,
|
||||
},
|
||||
])
|
||||
.run()
|
||||
|
||||
expect(yield* project.list()).toEqual([
|
||||
{
|
||||
id: Project.ID.make("newer"),
|
||||
canonical: abs("/newer"),
|
||||
time: { created: 2, updated: 2 },
|
||||
sandboxes: [],
|
||||
},
|
||||
{
|
||||
id: Project.ID.make("older"),
|
||||
canonical: abs("/older"),
|
||||
@@ -61,9 +57,15 @@ describe("Project.list", () => {
|
||||
name: "Older",
|
||||
icon: { color: "#000000" },
|
||||
commands: { start: "bun dev" },
|
||||
time: { created: 1, updated: 1 },
|
||||
time: { created: 1, updated: 1, active: 4 },
|
||||
sandboxes: [abs("/older/sandbox")],
|
||||
},
|
||||
{
|
||||
id: Project.ID.make("newer"),
|
||||
canonical: abs("/newer"),
|
||||
time: { created: 2, updated: 2, active: 3 },
|
||||
sandboxes: [],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -83,6 +85,7 @@ describe("Project.update", () => {
|
||||
sandboxes: [],
|
||||
time_created: 1,
|
||||
time_updated: 1,
|
||||
time_active: 1,
|
||||
})
|
||||
.run()
|
||||
|
||||
@@ -111,13 +114,36 @@ describe("Project.update", () => {
|
||||
expect((yield* project.list())[0]).toEqual({
|
||||
id,
|
||||
canonical: abs("/update"),
|
||||
time: { created: 1, updated: expect.any(Number) },
|
||||
time: { created: 1, updated: expect.any(Number), active: 1 },
|
||||
sandboxes: [],
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("Project.activate", () => {
|
||||
it.effect("records activity without editing metadata time and throttles repeats", () =>
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
const project = yield* Project.Service
|
||||
const id = Project.ID.make("activate")
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({ id, worktree: abs("/activate"), sandboxes: [], time_created: 1, time_updated: 1, time_active: 1 })
|
||||
.run()
|
||||
|
||||
yield* project.activate(id)
|
||||
const first = (yield* project.list())[0]
|
||||
expect(first?.time.updated).toBe(1)
|
||||
expect(first?.time.active).toBeGreaterThan(1)
|
||||
|
||||
yield* db.update(ProjectTable).set({ time_active: 2, time_updated: 1 }).run()
|
||||
yield* project.activate(id)
|
||||
expect((yield* project.list())[0]?.time).toEqual({ created: 1, updated: 1, active: 2 })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
function remoteID(remote: string) {
|
||||
return Project.ID.make(Hash.fast(`git-remote:${remote}`))
|
||||
}
|
||||
|
||||
@@ -1556,6 +1556,16 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"metadata": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Session.Metadata"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -16308,9 +16318,13 @@
|
||||
"updated": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"active": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"required": ["created", "updated"],
|
||||
"required": ["created", "updated", "active"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Project.Vcs": {
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface Commands extends Schema.Schema.Type<typeof Commands> {}
|
||||
export const Time = Schema.Struct({
|
||||
created: NonNegativeInt,
|
||||
updated: NonNegativeInt,
|
||||
active: NonNegativeInt,
|
||||
}).annotate({ identifier: "Project.Time" })
|
||||
export interface Time extends Schema.Schema.Type<typeof Time> {}
|
||||
|
||||
|
||||
@@ -153,7 +153,7 @@ test("syncs VCS info and applies branch updates", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("proactively syncs project metadata newest first", async () => {
|
||||
test("proactively syncs project metadata most recently active first", async () => {
|
||||
const events = createEventStream()
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname !== "/api/project") return
|
||||
@@ -162,14 +162,14 @@ test("proactively syncs project metadata newest first", async () => {
|
||||
id: "proj_old",
|
||||
canonical: "/old/project",
|
||||
name: "Old project",
|
||||
time: { created: 1, updated: 1 },
|
||||
time: { created: 1, updated: 1, active: 3 },
|
||||
sandboxes: [],
|
||||
},
|
||||
{
|
||||
id: "proj_test",
|
||||
canonical: worktree,
|
||||
name: "OpenCode",
|
||||
time: { created: 1, updated: 2 },
|
||||
time: { created: 1, updated: 2, active: 2 },
|
||||
sandboxes: [],
|
||||
},
|
||||
])
|
||||
@@ -196,18 +196,18 @@ test("proactively syncs project metadata newest first", async () => {
|
||||
try {
|
||||
await wait(() => data.project.get("proj_test") !== undefined)
|
||||
expect(data.project.list()).toEqual([
|
||||
{
|
||||
id: "proj_test",
|
||||
canonical: worktree,
|
||||
name: "OpenCode",
|
||||
time: { created: 1, updated: 2 },
|
||||
sandboxes: [],
|
||||
},
|
||||
{
|
||||
id: "proj_old",
|
||||
canonical: "/old/project",
|
||||
name: "Old project",
|
||||
time: { created: 1, updated: 1 },
|
||||
time: { created: 1, updated: 1, active: 3 },
|
||||
sandboxes: [],
|
||||
},
|
||||
{
|
||||
id: "proj_test",
|
||||
canonical: worktree,
|
||||
name: "OpenCode",
|
||||
time: { created: 1, updated: 2, active: 2 },
|
||||
sandboxes: [],
|
||||
},
|
||||
])
|
||||
|
||||
@@ -1556,6 +1556,16 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"metadata": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Session.Metadata"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -16308,9 +16318,13 @@
|
||||
"updated": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"active": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"required": ["created", "updated"],
|
||||
"required": ["created", "updated", "active"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Project.Vcs": {
|
||||
|
||||
@@ -1556,6 +1556,16 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"metadata": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Session.Metadata"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"permissions": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -16308,9 +16318,13 @@
|
||||
"updated": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"active": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"required": ["created", "updated"],
|
||||
"required": ["created", "updated", "active"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Project.Vcs": {
|
||||
|
||||
Reference in New Issue
Block a user